与-Babylon-js-共赴千里之行-全-

与 Babylon.js 共赴千里之行(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

3D 应用和游戏开发的世界是一个广阔且不断变化的领域。通过 WebGL 将现代 GPU 硬件的所有令人惊叹的功能暴露给网络浏览器,任何人只要有一些 JavaScript 知识,就能实现 AAA 质量的交互式渲染。Babylon.js 正是实现轻松体验和强大应用(使用 WebGL 技术构建)的正确工具。

虽然浏览器软件和硬件标准的改变和演变以它们自己的速度和自己的时间表进行,但 Babylon.js 是一个优先考虑保持向后兼容性的框架。为 BJS 2.0 编写的代码在 BJS 5.20 中运行时几乎不需要任何修改,因此产品经理和利益相关者可以放心使用 BJS,对其代码的长期稳定性有信心。

如果 Babylon.js 是 WebGL 的通行证,那么这本书就是您掌握 Babylon.js 的通行证。好吧,我们得现实一点,你可能不会在本书结束时成为第二十级的 Babylon.js 开发者,但你会学到关键的概念和技术,这将使你能够选择继续前进!

所有这些开始变得像是一种糟糕的销售说辞,所以我们还是放弃这种虚伪,直接谈谈正题。你可能需要了解 3D 游戏或应用开发。作为一个人类,你也渴望娱乐。这本书试图通过尽可能避免无聊,同时仍然传递重要的知识,来满足这两个需求。娱乐与启迪,一包搞定。

本书面向的对象

这本书是为那些因为认为自己数学不好而避免编码的艺术家(给自己更多信用吧!),渴望离开电子表格的游戏设计师,以及梦想创造未知世界的开发者而写的。这本书是为那些想在课堂外学习的学生,想在课堂内让学生学习的老师,以及希望他们的青少年孩子学到一些东西的父母而写的。

本书涵盖的内容

第一章太空卡车操作手册,概述了太空卡车和 Babylon.js 的 3D 开发世界。

第二章Babylon.js 入门,通过一个简单的 3D 动画场景让我们开始(或重温)Babylon.js。

第三章建立开发工作流程,为快速未来的开发提供了一个坚实的设计和构建时间体验。

第四章创建应用,涉及构建一个有状态的应用程序,该程序将托管游戏。

第五章添加剪辑场景和处理输入,通过命令式创建一个动画“剪辑场景”,并学习如何处理不同类型的用户输入。

第六章实现游戏机制,开始了游戏主要路线规划阶段的构建。在这里,我们将现有的物理引擎与轨道力学和模拟重力相结合。

第七章处理路线数据,涉及添加与空间生物群落相对应的随机遭遇表。

第八章构建驾驶游戏,带我们了解动态生成路线并允许玩家沿着路线驾驶。

第九章计算和显示评分结果,讨论了使用 GUI 编辑器捕获和显示玩家性能统计的复用对话框。

第十章通过光照和材质改进环境,介绍了我们如何通过增强关键视觉元素来改善游戏的外观和感觉。

第十一章探索着色器的表面,讨论了扩展类比来解释着色器以及编写不涉及编写任何着色器代码的着色器代码。

第十二章测量和优化性能,解释了测试运行时性能的启发式方法和改进策略,以及使用 SceneOptimizer 工具的动态运行时优化。

第十三章将应用程序转换为 PWA,探讨了将应用程序准备为渐进式 Web 应用程序PWA)的过程。然后我们将其发布到主要应用商店,并添加离线使用支持。

第十四章扩展主题,扩展,在深入研究 CMS 或电子商务场景中的逼真光线追踪和 Babylon.js 之前,探讨了使用 WebXR 和 Babylon Native 的 AR/VR。

为了充分利用这本书

在参与这本书中的活动之前,您至少应该对 JavaScript 有初步的了解,至少到您不会因为看到可能最初不熟悉的代码而感到困扰。了解基本 3D 概念和术语也有帮助。如果您是 Babylon.js、JavaScript 或 3D 开发的初学者,那么从 Babylon.js 的起始页面doc.babylonjs.com/journey/theFirstStep开始是一个绝佳的选择。

建议使用具有 Mozilla 或 Chrome 渲染引擎的网页浏览器,因为它对各种 WebGL 和 WebGPU 功能的支持程度最高。已知 Safari(WebKit)在支持列表中与所列的其他引擎相比,功能相似但支持程度显著落后。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

Babylon.js 社区是获取与 BJS 相关一切帮助的最有价值的资源。作为一个开源项目,Babylon.js 通过其致力于的贡献者社区保持活力。谁可以贡献?任何人。可以贡献什么?几乎任何东西。加入 BJS 社区,在官方论坛forum.babylonjs.com上,与团队见面!

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/jelster/space-truckers/。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/CGb69

使用的约定

本书中使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“createSpinAnimation方法从createStartScene调用,以便将spinAnim变量提供给场景的其他控制代码。”

代码块按以下方式设置:


planets.forEach(p => {
    p.animations.push(spinAnim);
      scene.beginAnimation(p, 0, 60, true, BABYLON.Scalar.RandomRange(0.1, 3));
});

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:


planets.forEach(p => {
        glowLayer.addExcludedMesh(p);
        p.animations.push(spinAnim);
        scene.beginAnimation(p, 0, 60, true, BABYLON.Scalar.RandomRange(0.1, 3));
    });

任何命令行输入或输出都按以下方式编写:

npx webpack –config webpack.common.js

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“点击运行现在应该显示一个在天空中可以旋转的漂亮星系。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。

copyright@packt.com并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《与 Babylon.js 走得更远》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

第一部分:构建应用程序

本书的第一部分建立了将在后续章节中利用的重要基础。从对太空卡车和 Babylon.js 的概述开始,我们将构建游戏托管应用程序的主要支柱。虽然建议对 Babylon.js 有基本了解,但主要要求是您需要对 JavaScript 或类似编程语言有所了解。

本节包括以下章节:

  • 第一章, 太空卡车操作手册

  • 第二章, Babylon.js 入门

  • 第三章, 建立开发工作流程

  • 第四章, 创建应用程序

  • 第五章, 添加剪辑场景和处理输入

第一章:Space-Truckers 操作手册

并不是一种非常情感成熟的立场,仅凭封面来判断一本书,但你看过这本书的封面吗?如果你喜欢,那么请,无论如何,你这位反文化影响者,请继续阅读这本书吧!

如果出于某种原因你不喜欢封面,那么恭喜你,你实际上是在翻过新的一页来看里面的内容——与那些一些肤浅的笨蛋不同。毕竟,我们不会做出那种微不足道的评判。

注意

有时,相关信息将呈现在这些注意事项框中。有时,这些相同的框将包含完全无关但可能是不敬的信息。在任何时候,或者在任何时候(有时),你应该注意这些框中的内容。

无论你是封面团队还是内容团队,很明显,你非常聪明,举止得体,仅仅因为你开始阅读这本书。我们将在接下来的 14 章中一起踏上旅程。这不是你在睡前寻找观看内容时翻阅频道时可能会遇到的旅程。这是一次穿越广阔而浩瀚的Babylon.js生态系统的旅程。这不是一次狩猎之旅,但确实是一次旅行。然而,它不是一次奥德赛。主要是因为你实际上不必真正去任何地方,而且当你不阅读这本书时,你可以随时回到你的日常生活,也许还有其他原因。

重要注意事项

就像它的不那么出名的表亲 Note 一样,重要的注意事项框偶尔会露面。通常,这些用于你可能后悔不知道的事情之前…

在我们这次旅途中,我们将覆盖大量内容,但你们不会是无准备的旅行者。我们的总体目标是构建一个由通用网络应用程序托管的游戏。在接下来的三个独立部分中,我们将逐步完成以下三件事:

  • 创建并设置一个应用程序和开发工作流程,为Space-Truckers: The Video Game!提供一个生存空间

  • 在我们的应用程序上添加额外的功能(托管Space-Truckers: The Video Game!

  • 放大细节级别,以接受广泛的增强并补充我们的良好知识

每一章都将建立在上一章的基础上。有可能某一章中的代码需要在后续章节中进行修改,这应该被视为我们不断深化对如何构建应用程序以实现当前目标的理解的反映。除了这一章之外,每一章都包含指向游戏代码的链接,这些链接与章节文本的上下文相同,此外还有针对内容的实时演示和 Playground 链接。

在我们构建应用的同时,我们将越来越少地提供逐行代码的细节,转而提供更多上下文和/或与“内部工作原理”相关的信息。别担心,代码和游乐场示例仍然在那里帮助您找到方向!我们将探讨一些概念,这些概念本身就可以占据比这本不太短的书籍还要长的整本书,而我们将在更少的篇幅中展开这些话题。因此,我们将从高层次覆盖一些领域,而其他领域将进行更深入的讨论。

我们将从玩家的角度开始介绍游戏,然后转向查看底层游戏和应用设计。作为结尾,我们将通过参观太空卡车 GitHub 仓库和其他在线资源来结束这次第一段旅程。让我们以经典文学的方式从结尾开始。

注意

对于这个场景的电影版本,想象一下当我们过渡到不同的世界时,有一种闪烁的溶解效果和适当的声音效果…

介绍太空卡车世界

天文学家最近开始收到一个神秘的信号,显然来自我们的太阳系之外。这个信号远非随机噪声,它似乎包含以文本、音频和视觉内容形式的结构化数据——一个外星传输!传输从术语和数学的基本入门开始,迅速发展到描述某种大型塑料盘,上面印有信息中称为“多媒体互动内容”的东西,然后连接到显示设备并旋转(多么荒谬!)数千转/分钟,同时激光束读取烧录在旋转盘上的凹槽。激光束。凹槽。旋转的轮子。所有这些都是荒谬的,但外星人的感觉又怎么能解释呢?

以下是从该传输中恢复的内容重建,并烧录在现在被称为“死海光盘”上的内容。由于其穿越时空的性质,传输的部分内容没有收到,数据无法恢复。同时,数据的关联性质导致其他部分被损坏。因此,您即将看到的许多图像和静态画面代表的是使用我们可用的最佳工具和资源修复的数据。

才华横溢的专业工程师、科学家,甚至是社会学家团队长期辛勤工作,以重建我们认为留下或发送给我们这个记录的人们——或我们收到的记录——的外观:

图 1.1 – 对太空卡车传输发起者的外观的最佳猜测

图 1.1 – 对太空卡车传输发起者的外观的最佳猜测

下一个部分包含从传输中恢复的重建文本和图像内容。因为原始信息是以象征性表达而非任何人类语言,所以最新的 GPT-3 文本生成 AI 被训练在传输的符号上,以便它能产生以下内容,并与其他本书内容格式保持一致。

那么,你想要成为一名太空货车司机吗?

BEGIN TRANSMISSION

图 1.2 – 太空货车司机传动系统的重建。可能是一幅“一天的生活”图像

图 1.2 – 太空货车司机传动系统的重建。可能是一幅“一天的生活”图像

成为太空货车司机不是胆小鬼或孤独者能做的事情。这里充满了危险和危险 – 但也有财富和名望的诱惑。自从传奇太空货车司机温切尔·张(呼号:火箭猫)著名的“大巡游”以来,整个系统中的每个孩子都长大渴望效仿他。在用尽他的反作用质量交付货物后,他救了数百万在太空土豆大饥荒后受苦的挨饿儿童。遗憾的是,这一无私的行为让他的太空车漂泊无家可归。张的太空货车在漂向太阳之外的黑暗中失踪。他最后的传输,尽管混乱,但包含了一段可恢复的文本片段:

“冷酷无情的方程式不会关心饥饿或饥荒。 <无法解读>…[b]因为我们都是太空货车司机。这就是我们所做的。”

太空货车司机张是成为太空货车司机意味着什么的杰出典范,但公平地说,这个行业的阴暗面也不容忽视。未被公开的是太空货车司机的高流失率。有些人因为独自在星星之间而发疯,而有些人则在完成任务后拒绝再次外出。其他人离开一个地方,却永远无法到达他们预定的目的地。

图 1.3 – 太空货运是一项危险的事业

图 1.3 – 太空货运是一项危险的事业

当然,计算机可以帮助,其他技术也贡献于帮助使太空货运安全可靠。然而,在处理不可预见的情况时,没有任何硬件或软件能与人类大脑的湿件相提并论,这就是为什么太空货车司机需要坐在他们的太空车驾驶座上。

在任何太空车轮能够触碰到太空路面之前,我们的司机需要知道去哪里。太空调度中心提供路线规划服务,通过他们的详细轨道和发射模拟,可以评估和尝试不同的潜在路线,而不会对太空货车司机造成风险。

图 1.4 – 规划路线涉及调整发射时机以及正确瞄准。左侧栏控制发射推力 – 越高越快

图 1.4 – 规划路线涉及调整发射时机以及正确瞄准。左侧栏控制发射推力 – 越高速度越快

尽管有风险,但潜在回报相当高。完成太空运输对太空货车司机来说有可变支付,太空币的奖励或扣除基于驾驶员在战场上的表现。模拟路线的因素包括总通行时间、消耗的燃料(发射力)以及总行程距离。

图 1.5 – 当一切顺利时,太空货运的报酬很高

图 1.5 – 当一切顺利时,太空货运的报酬很高

可以遇到许多不同的障碍,没有两条路线是相同的,但评分因素确保在比较运行时,高分榜是 G.O.A.T. 太空货车的不二仲裁者。

注意

在这个语境中,G.O.A.T. 并不是指任何动物。所有时间最伟大的太空货车司机是一个精选和精英群体 – 适当表示尊敬!

在太空货运中,时间至关重要,但安全也同样重要。通过关注后者以服务于前者,太空货车司机有最大的机会完成太空运输并获得在太空海滩上花费工资的机会。

永远不要忘记,太空货车司机 – 行星运动的冷酷、硬性方程式并不关心你是否拥有足够的空气呼吸或热量保持温暖。在仪器故障的情况下,随身携带计算尺,去寻找你的财富,运送货物吧!

图 1.6 – “太空货车司机”及其“太空装备”的恢复图像。太空货车司机是前景中的小人物

图 1.6 – “太空货车司机”及其“太空装备”的恢复图像。太空货车司机是前景中的小人物

END TRANSMISSION

对于那些发送招募传单的人来说,太空货车司机的生活肯定充满了迷人的财富和危险的旅行!回到现实世界是困难的,但重要的是我们要分解太空货车司机设计和组装的各个要素。理想情况下,随着你通过这本书的进展,你将拥有这个基础,帮助你保持对一切如何放置和组合的了解。

太空货车:视频游戏设计

太空货车的核心思想很简单:将东西从 A 点运送到 B 点,在太空中!(不一定要画出最后一个词,但这有助于营造氛围。)作为一款游戏,它被分为几个不同的阶段或状态:

  • 降落(主页)屏幕

  • 启动屏幕

  • 菜单(包括高分)

  • 路线规划

  • 驾驶 + 得分

每个这些屏幕(在这里用作“状态”的同义词)都将被建立,然后在本书的进程中进一步改进,以及一个支持并协调它们之间的基础应用程序。

降落

当玩家导航到 space-truckers.com(或测试网站,dev.space-truckers.com)时,首先看到的是这个页面。这是一个简单的 HTML 页面,包含一个行动号召:“启动。”然而,在底层,这个 HTML 页面是主应用程序画布的宿主——所有渲染输出都绘制在这个 WebGL 上下文中。它负责加载打包的 Web 应用程序以及注册一个 Service Worker(参见第十三章**,将应用程序转换为 PWA)来管理和预取资源。作为 DOM 宿主,它提供了对网络浏览器以及通过它对宿主机器资源的访问,例如播放声音或从游戏手柄或 VR 硬件读取输入的能力。更多关于这方面的内容,请参阅第三章**,建立开发工作流程

启动画面

在音乐和喜剧中,暖场表演作为将观众带入特定心态或情绪的方式,在主要头牌表演之前进行。毕竟,当你已经调到 7 的时候,把音量调到 10 以上要容易得多!Space-Truckers 的启动画面也起到了这个作用,同时为我们提供了一个展示底层框架并宣称这款游戏由 Babylon.js 提供支持的机会。一旦简短的动画内容完成,应用程序进入“吸引模式”,以吸引玩家继续游戏。

菜单

游戏的交通枢纽,主菜单,是玩家开始新游戏、查看高分、返回主页以及可能进行更多操作的地方。音效和动画选择图标为闪烁的背景增添了一丝动感。菜单系统最初在第四章**,创建应用程序的“Space-Truckers:主菜单”部分中进行了介绍。

路线规划

主要游戏阶段之一,路线规划模拟,是玩家成为内容创作者的地方。使用俯视图,驾驶员在出发前规划他们的路线。从初始的起始轨道,靠近最内层的行星,玩家必须平衡使用多少发射力与瞄准和时机,以便将模拟的货物放置在通往目标行星的路径上。一旦发射,货物完全受重力和大卫·牛顿爵士的控制。小贴士:瞄准你想要到达的地方之前,但一定要考虑到太阳的引力。因为这是一个路线模拟,失败没有后果——玩家可以自由尝试多次,以找到下一阶段驾驶的完美路线。

驾驶与得分

在规划好路线后,玩家接下来需要亲自驾驶,引导他们的空间卡车通过运输通道,同时避免与在路线规划阶段记录的随机事件发生碰撞。玩家的单位在自由落体中漂移,因此任何给定方向的累积速度将保持不变,除非后来被相反的加速度抵消。碰撞会导致损坏,足够的损坏将使卡车及其货物被摧毁。

![图 1.7 – 驾驶阶段的碰撞有后果图片 1.07 – B17266

图 1.7 – 驾驶阶段的碰撞有后果

在一个更积极的消息中,完成课程后,玩家的得分将被计算。有几个不同的因素会影响最终总分的计算。路线的长度、模拟完成路线所需的时间与玩家所用的时间,以及路线规划中的初始发射速度都是影响评分的因素之一。如果玩家的分数足够高,那么它将取代一个之前的高分记录者,将玩家的所选首字母放入传奇大厅。

这就是游戏的核心内容。与任何此类高级概述一样,它必然缺少一些细节,但它为我们提供了关于本书将要开发内容的整体图景。要了解更多细节,我们首先需要了解在哪里可以找到这些细节,以及如何在 GitHub 仓库中为空间卡车手找到相关的背景信息。

空间卡车手:仓库

探索是发现新知识的重要学习策略。其对立面,利用,是将知识转化为可操作技能的同样重要的策略。最大化学习的关键是适当应用每种类型的学习,在适当水平和时间。通过两者之间的紧密迭代交流,可以在短时间内学到很多东西。

我们的旅程中有许多停留点和路标,以帮助和引导我们朝着目的地前进,为了最大限度地提高学习效果,每一章都代表了我们朝着目标进化的一个阶段,包括可运行的可执行示例(探索)以及在该旅程阶段的应用程序的确切源代码。

![图 1.8 – 在旅途中查看应用程序源代码与阶段的关系图片 1.08 – B17266

图 1.8 – 在旅途中查看应用程序源代码与阶段的关系

我们通过使用 Git 分支以简单的方式完成这项任务——每个章节都有一个分支,涉及应用代码。此外,每个章节可能包含一个或多个针对该章节内容的特定 Playground 片段(有关更多信息,请参阅第二章在 Babylon.js 中入门)。片段在许多方面都很整洁,其中之一是它们可以有多个版本。在片段的不同版本之间切换是查看示例如何演变的好方法,并且可以帮助了解为什么特定的代码片段表现如故。

可能事情进行得很好,但后来你发现自己卡在了某个你无法解决的问题上。这也是可以的——有地方你可以寻求帮助!在github.com/jelster/space-truckers/discussions的讨论板上创建一个帖子或添加到现有的帖子中,以提问、评论或对仓库和/或书籍的内容表示关注。更普遍的问题可以在 BJS 社区论坛上发布——forum.babylonjs.com。为 GitHub 和 BJS 论坛创建账户相对快速且不痛苦。

小贴士

如果你计划创建 BJS 论坛和 GitHub 账户登录,先注册 GH 可以节省一半的努力。然后,当你创建论坛账户时,你可以选择使用 GitHub 登录选项,提供你新创建的 GH 账户信息。

Space-Truckers 仓库不仅包含源代码和讨论,还托管了游戏的 Issue Tracker(github.com/jelster/space-truckers/issues),人们可以在那里请求新功能或提交错误报告——这也是那些希望为项目做出贡献的人可以查看以找到适合他们能力的事情的地方。

小贴士

另一个通过本书成本按比例提供的专业小贴士:浏览带有标签 good-first-issueneeds-help 的问题。这些是仓库维护者需要帮助或认为问题代表了代码库的温和引入的问题。

社区贡献是开源软件OSS)的核心,但由于它们主要是由志愿者驱动的,因此工作总是比能够完成工作的人多。因此,每当有人提交一个解决现有问题的 Pull Request(要合并到代码库的更改集)时,大多数维护者都会感到非常高兴!

小贴士

对这些内容感到厌倦了吗?完全可以理解。最后的建议:即使是像 BJS 这样的项目,有很多人全职维护,也存在这个问题。维护者可能不需要为了保持服务器运行而四处筹集捐款,但他们确实需要争取时间来完成我们希望他们完成的所有事情!

当感觉像是通过消防水龙带吸收所有新知识时,综合和掌握新事物可能会很困难。这就是为什么 Space-Truckers 的代码库按章节进行分支。尽管单个章节不一定与主分支或开发分支(分别代表生产环境和测试环境)中代表的主游戏版本相似,但每个分支都具有在书中该点所需的复杂度,不再更多。换句话说,应用程序的演变将反映我们旅程的展开。

摘要

接下来的十三章每章都代表我们旅程的一个里程碑,还有很多东西要看和完成。驶上太空高速公路,前方太空道路似乎延伸到无限。实际上,每条路在旅程开始时看起来都是这样。通过专注于眼前的事物,无限可以变得有限,复杂的任务也可以变得可管理。

就像这本书被分成章节和部分一样,Space-Truckers 也被分成不同的阶段或状态。着陆页面是开始游戏的发射台(有意为之),而启动屏幕则准备观众并设定氛围。同时,主菜单屏幕作为主游戏状态和其他状态之间的导航中心。

游戏有两个(左右)阶段。路线规划是玩家使用轨道力学模拟来规划太空货物的路线,从起点到达目的地行星。发射的方向和力量由玩家在发射前设定,发射的时间也是玩家决定路线的重要因素。

在规划了路线之后,下一个游戏阶段将使用该路线创建一个充满障碍物(随机遭遇)的隧道,玩家现在必须驾驶太空货车通过隧道到达终点。时间很重要,但将货物尽可能完好无损地运送到目的地也同样重要。一旦到达目的地,第三个、准游戏阶段就会登场。

得分是通过几个因素来计算的,这些因素将在第九章**,计算和显示得分结果中详细说明。玩家在路线规划中的决策以多种方式影响最终得分,从时间目标到燃料成本。只有最高分会被保存在高分屏幕上,这是应用程序的 Web 和 PWA 版本都有的一个功能。

所有关于 Space-Truckers 的工作都跟踪和管理在Space-Truckers: GitHub 仓库中。此外,书中(除少数例外)的每一章在源代码中都有自己的分支。这允许你在与相应章节的内容的上下文中查看整体应用程序的状态。你还可以通过在 Space-Truckers: 讨论板或 BJS 官方社区论坛上发帖来获得额外的帮助。

接下来,我们将通过回顾 BJS 框架和生态系统的基本知识,逐步积累一些动力。我们将探讨一些工具、资源和技巧,并在必要时(重新)介绍 BJS 中的渲染工作原理。我们将了解 Playground,并通过创建一个简单的加载动画来开始构建我们的应用程序的过程。系好安全带,太空卡车手——我们即将踏上征程!

第二章:加强 Babylon.js

带着夸张的风险,Babylon.jsBJS)在如何轻松、快速且有趣地处理 3D 图形和游戏方面堪称不可思议。大多数游戏和图形引擎在大小和计算资源需求方面都有相当大的体积,但 BJS 不同,因为它可以在网页浏览器中运行。BJS 团队创建了一个丰富的基于网页的工具生态系统,涵盖了广泛的开发生命周期和工作场景,从多个角度支持开发者和设计师。在建立一些共同词汇并复习一些基础知识之后,我们将从Babylon.js 游乐场PG)开始我们的旅程。在这一章之后,我们将通过创建和渲染一个基本的动画场景来为 Space-Truckers 打下基础,这个场景使用了 PG 以及资产库中的内容。

为了从我们现在所在的地方到达我们想要到达的地方,我们将工作分为以下部分:

  • 补充或更新 Babylon.js 知识

  • 在游乐场中构建我们的场景

  • 动画轨道

  • 扩展主题

技术要求

就像软件中的大多数事情一样,你将使用 Babylon.js 获得最佳结果。PG 片段只需要一个支持WebGL的网页浏览器,但对于一些基于桌面的 BJS 网页工具集,如节点材质编辑器NME),则需要桌面浏览器。强烈建议在 PG 中输入代码时使用键盘。关于浏览器支持,尽管在特定设备和平台周围有一些例外,但 Edge、Chrome 和 Firefox 的最新版本都支持 WebGL2,并且对较新的 WebGPU 功能的支持在不断增加。有关支持 WebGL2 的浏览器供应商的最新列表,请参阅caniuse.com/webgl2

补充或更新 Babylon.js 知识

当开始一个新的项目时,很容易被需要完成的不同事情的数量所压倒。如果再加上不熟悉的技术或领域,即使是经验丰富的软件老手在面对挑战时也可能感到有些退缩。这种感觉是可以接受的!克服并超越这种感觉的关键既困难又简单:你只需要找到一个原子化、定义明确的任务,然后只做那个任务。在完成几个这样的任务之后,你可以退一步,根据你现在的知识重新评估事情。很可能会发现,你最初认为需要做的工作其实并不需要。

无论你是探索 BJS 可能性的资深游戏开发者,还是从未编写过游戏的初学者,从简单开始并迭代构建可能是获得可用、即时结果的最佳方式。让我们从基础知识开始。以下截图是 BJS 4.2 版本发布内容的一部分,展示了 BJS 如何以高视觉保真度渲染场景。

图 2.1:来自 Babylon.js 主页的实时交互式演示。在瓶子和平面(以及内部)可以看到半透明的阴影、反射和折射,就像不同物质在现实世界中投射出不同的阴影一样。(https://playground.babylonjs.com/#P1RZV0)

图 2.1:来自 Babylon.js 主页的实时交互式演示。在瓶子和平面(以及内部)可以看到半透明的阴影、反射和折射,就像不同物质在现实世界中投射出不同的阴影一样。(playground.babylonjs.com/#P1RZV0

BJS 的基本知识

BJS 是一个基于 WebGL 的全功能 3D 渲染引擎,用 TypeScript 编写并编译为 JavaScript。尽管通常通过网页浏览器访问,但当前版本不需要 HTML DOM 或 Canvas 元素,这意味着它可以在服务器上“无头”运行。BJS 团队有一个非常明确的目标和使命,如 BJS 主页所示(www.BJS.com):

“我们的使命是创造世界上功能最强大、最美丽、最简单的 Web 渲染引擎之一。我们的热情是让它对每个人完全开放和免费。我们是艺术家、开发者、创造者和梦想家,我们希望让它尽可能简单,以便每个人都能将他们的想法变为现实。”

BJS 支持广泛的输入和输出场景,从游戏手柄和基于加速度计的输入到单视口或多视口输出(例如,VR/AR)。该引擎的完整规格列表可在www.babylonjs.com/specifications找到。从规格中不太明显的是,对 WebGPU 的支持仅限于浏览器厂商对标准的实现,因此如果你看到有关浏览器发布 WebGPU 支持的新闻,你可以确信 BJS 将能够充分利用它,而无需你做任何事情!

小贴士

当我使用来自其他 3D/图像编辑工具(如 Blender)导入的资产时,我总是忘记应用坐标约定。BJS 使用的 3D 坐标系是“左手坐标系”,这意味着正y轴(默认情况下)指向“向上”的方向,正x轴指向“向右”,正z轴指向“相机方向”。

从入门开始学习

任何浏览 BJS 文档的人都会很快意识到该文档是多么的详尽和全面。鉴于入门内容的优质,本书尝试重新创建doc.babylonjs.com/start中的基本教程将是毫无意义的浪费宝贵空间。如果您是第一次冒险进行游戏开发、BJS 或 JavaScript,强烈建议您花时间至少浏览一下前面链接的入门教程。不用担心离开——您回来时,一切都会像您离开时一样完好无损!

工具箱工具

作为 JavaScript 基础的优点之一是,它非常容易提供基于 Web 的工具,允许用户在紧密的迭代循环中实时编码和渲染。BJS 的游乐场PG)可能是 BJS 工具链中最突出的成员,但这不应减少我们将要介绍的其他工具的实用性和重要性。以下表格总结了各种工具及其用途:

在整本书中,我们将大量使用 PG;我们将用它来快速组合一段代码或测试一个概念,然后再将其集成到我们的应用程序代码中。同样,检查器(及其相关工具)也将因其强大的场景调试功能而得到大量使用。最后,随着我们深入到《Space-Truckers》的制作,NME 将在本书的后面部分进行介绍。

注意

在本书中,游戏一词的典型用法是指整个应用程序中专门用于游戏机制、逻辑和循环的部分。

BJS 的资产类型

BJS 支持许多不同类型的文件和格式,无论是直接还是间接(通过导出插件)。在选择和/或为您的游戏创建资产时,重要的是要制定一个生产工作流程,以最大限度地减少摩擦,同时不牺牲质量——我们将在下一章中了解更多关于这一点。以下是 BJS 支持的几种最常见的第三方工具和文件类型:

  • 纹理/图像:

    • DDS (DXT1, 4bpp, 和 RGBA)

    • PNG/JPEG/BMP

    • TGA

    • HDR

  • 3D 模型:

    • GLTF (首选)

    • OBJ

    • STL

    • BLENDER/3DS Max/Maya (导出插件)

  • 声音:

    • WAV

    • MP3

    • MP4

    • M4A

  • 字体:

    • TrueType

    • OTT

然而,对我们当前目的来说,更相关的是 BJS 的资产库。您可以在doc.babylonjs.com/toolsAndResources/assetLibraries查看资产类别并按类别浏览条目,但资产库的真正力量在于能够从 PG 中引用和加载它们!让我们从创建场景开始,就是这样。打开您选择的浏览器并前往 BJS PG:playground.babylonjs.com

构建游乐场场景

Babylon.js 游乐场的设计宗旨是提供用户以最简单、最短的可能路径来渲染场景中的内容。打开你选择的网页浏览器,导航到playground.babylonjs.com/,你将看到代码片段的基本轮廓。这个基本的模板片段简单地创建了一个新的场景和一个相机来渲染它,但这也是一个很好的起点!

在游乐场的左侧是代码编辑器,右侧是渲染画布。关于游乐场,重要的是要知道每个片段在两个方面是独特的,这两个方面都包含在片段的 URL 中。第一个井号(#)符号之后的字符是片段的 ID,第二个井号之后的数字是修订版本。每次创建片段时,都会分配一个唯一的标识符,每次保存该片段时,都会创建一个新的修订版本。例如,#L92PHY#36指向一个示例,展示了 FPS 相机中的多个视口,当前修订版本为 36。因此,只需更改 URL,就可以逐步通过特定片段的修订历史。

注意

#0UYAPE#42. That is, snippet 0UYAPE at revision 42.  

尽管我们将在游戏中使用 PG 的代码片段,但我们需要做一些初步的结构化工作,以便我们可以轻松且可靠地在我们的 PG 片段和源代码库之间传输代码(更多内容请参阅*第三章,建立开发工作流程)。在本书和代码片段中,我们将尽可能使用ES6**语法。这使我们能够访问一些重要的语言特性,我们将利用这些特性来帮助保持我们的代码可读性和可维护性。

小贴士

ES6 建议:优先选择let而不是var

所有的重点都在提升(hoisting)和闭包(closures)上。使用var关键字声明的变量在其声明的作用域内有效,但也可能在包含的作用域(称为“提升”)内有效。此外,你可以在使用之前引用一个var,而不会抛出运行时错误。当一个变量使用let语句声明时,它只在其声明的作用域内可用,并且必须在使用之前声明;否则,将会抛出错误。通常,你应该优先使用let而不是var,因为它将更容易防止和暴露过于常见但可能相当微妙的缺陷。当然,如果你不打算更改值,你应该使用const而不是let

建立 AppStartScene

一个新的 PG 片段从单个代码块开始——createScene函数。正如代码注释也指出的那样,enginecanvas全局变量在窗口的上下文中可用。

重要提示

在 BJS 4.2+中已经移除了 HTML Canvas 元素作为依赖项,但出于向后兼容性的原因,涉及 HTML Canvas 元素的方法仍然按预期工作。

修改 createScene 函数

为了使代码的重用更容易,我们将对初始函数模板进行一些小的修改。我们不会将场景的所有逻辑都放入同一个createScene函数中,而是尽可能地将逻辑细分到原子函数中。初始化例程将在一个新函数中完成,该函数将返回一个包含已填充场景对象的对象:


let createScene = function () {
    let eng = engine;
    let startScene = createStartScene(eng);
    return startScene.scene;
}; 

一个敏锐的观察者会注意到我们尚未实现createStartScene函数,这当然是下一步。它的目的是创建和初始化场景及其元素 - 请参阅以下列表。低摩擦变化至关重要,因此为了便于以后更改,我们将每个功能部分放入其自己的函数中(有意为之):

  • 弧形旋转相机

  • 点光源

  • 星星(太阳)

  • 背景天空盒

  • 行星 - 四颗岩石行星和一颗气态巨行星

是时候填写并填充这个新的函数createStartScene了。首先,我们正在创建场景和相机,在调用即将编写的函数(加粗)之前指定一些具体细节,这些函数将创建相应的元素:


function createStartScene(engine) {
    let that = {};
    let scene = that.scene = new BABYLON.Scene(engine);
    let camAlpha = 0,
        camBeta = -Math.PI / 4,
        camDist = 350,
        camTarget = new BABYLON.Vector3(0, 0, 0);
    let camera = that.camera = new BABYLON.ArcRotateCamera("camera1", camAlpha, camBeta, camDist, camTarget, scene);    
    let env = setupEnvironment(scene);
    let star = that.star = createStar(scene);    
    let planets = that.planets = populatePlanetarySystem(scene);
    camera.attachControl(true);    
    return that;
}

为了节省您在脑海中计算的努力,camBeta(或,相机相对于目标纬度的弧度值)大约为 0.785 弧度 - 45 度,位于围绕camDist半径目标的想象圆的赤道和极点之间。当然,这段代码目前还不能编译或运行,因为我们还没有定义setupEnvironmentcreateStarpopulatePlanetarySystem。为这些函数添加占位符实现以确保代码按预期运行。生成的场景是空的,但这是我们进度中的一个良好检查点。现在是时候填充占位符并让我们的场景活跃起来!在继续之前,别忘了保存(Ctrl + S)您的代码片段。

设置环境

默认环境相当单调且昏暗。场景的主要光源将是一个位于星系中心的点光源,而天空盒则提供了场景的透视感。天空盒的纹理特别引人注目,因为一个看起来吸引人的天空盒在文件大小方面往往相当大。我们关心这一点,因为我们打算将这个场景用作加载图形,这意味着它需要尽可能快地加载并开始渲染。通过互联网连接加载大纹理不太可能帮助我们实现这一目标,因此我们将使用 Babylon.js 的程序纹理库中的星场程序纹理(见doc.babylonjs.com/toolsAndResources/assetLibraries/proceduralTexturesLibrary以获取可用的程序纹理的完整列表)即时创建纹理。

提示

每个darkmatter,它控制空洞(空隙),以及distfading,它控制渲染纹理的清晰度或模糊度。以下代码中列出的值是通过试错得到的,所以请尝试看看你最喜欢什么!

PointLight,正如其名所示,是一个从空间中的单个点向球形壳辐射光线的光源。由于场景的黑暗和其较大的尺寸,光线在设置漫射和镜面颜色通道的类似太阳的颜色之前会增强。我们使用createDefaultEnvironment方法以及一些之前定义的选项来创建天空盒和相关的背景材质。该方法返回一个EnvironmentHelper实例,我们将友好地将其返回给setupEnvironment的原始调用者:


function setupEnvironment(scene) {
    let starfieldPT = new BABYLON.StarfieldProceduralTexture("starfieldPT", 512, scene);
    starfieldPT.coordinatesMode = BABYLON.Texture.FIXED_EQUIRECTANGULAR_MIRRORED_MODE;
    starfieldPT.darkmatter = 1.5;
    starfieldPT.distfading = 0.75;
    let envOptions = {
        skyboxSize: 512,
        createGround: false,
        skyboxTexture: starfieldPT,
        environmentTexture: starfieldPT
    };
    let light = new BABYLON.PointLight("starLight", BABYLON.Vector3.Zero(), scene);
    light.intensity = 2;
    light.diffuse = new BABYLON.Color3(.98, .9, 1);
    light.specular = new BABYLON.Color3(1, 0.9, 0.5);
    let env = scene.createDefaultEnvironment(envOptions);
    return env;
}

点击运行应该会显示一个在天空中可以旋转的漂亮星系。如果一切正常,现在是一个保存你工作的好时机。

![图 2.2 – 星系天空盒环境图片

图 2.2 – 星系天空盒环境

诞生一颗星星

我们星星的网格是一个简单的球体,但当我们添加标准材质和一些颜色通道时,结果是一个单色调、看起来平坦的圆圈——不太像“星星”。通过结合一个BABYLON.Texture,我们可以用很少的努力得到更细腻的外观:


function createStar(scene) {
    let starDiam = 16;
    let star = BABYLON.MeshBuilder.CreateSphere("star", 
        { diameter: starDiam, segments: 128 }, scene);
    let mat = new BABYLON.StandardMaterial("starMat",
        scene);
    star.material = mat;
    mat.emissiveColor = new BABYLON.Color3(0.37, 0.333,
        0.11);
    mat.diffuseTexture = new BABYLON.Texture
        ("textures/distortion.png", scene);
    mat.diffuseTexture.level = 1.8;
    return star;
}

在不改变diffuseTexture.level值的情况下,emissiveColor往往会淡化扭曲或被漫射纹理的像素值完全熄灭。这个值,1.8,是通过试错得到的(就像在应用设计/游戏开发中经常出现的许多“魔法数字”一样)。如果你最近没有保存进度,这是一个很好的检查点来保存你的工作。

![图 2.3 – 发射色与漫射扭曲纹理的组合图片

图 2.3 – 发射色与漫射扭曲纹理的组合

产生行星

剩下唯一需要创建的顶级场景元素是populatePlanetarySystem函数。这个实现的经典例子是组合软件模式的强大功能——这是一个我们稍后会再次讨论的话题。有一个可能被认为是中心控制逻辑的形式,即populatePlanetarySystems,它负责定义各种行星体的数量和独特属性。然后它要求另一个函数,新的createPlanet方法,来处理实际对象的构建。最后,它将行星收集到一个数组中,并将其返回给调用者。

我们希望能够创建具有不同特性的不同类型的行星,因此在我们的populatePlanetarySystems方法中,我们创建了一个对象数组,用于定义每个行星。有关行星数据的完整列表,请参阅playground.babylonjs.com/#0UYAPE#26


let hg = {
    name: "hg",
    posRadians: BABYLON.Scalar.RandomRange(0, 2 * Math.PI),
    posRadius: 14,
    scale: 2,
    color: new BABYLON.Color3(0.45, 0.33, 0.18),
    rocky: true
}; //...

posRadians属性生成介于 0 到 360 度(以弧度为单位)之间的随机值,而posRadius属性指定行星应位于原点的距离——它距离太阳有多远。行星的整体大小由其scale因子决定,而color属性。我们稍后将介绍最后一个属性。场景的缩放可能很棘手,但您可以使用相对缩放指南来帮助确定适当的数字范围。

您不必坚持现实数字——您是否曾经被告知“太空很大。真的很大”?事实上,它确实太大,无法适应我们的小视口,因此当选择行星的posRadius时,可能更容易从不同的角度来考虑数字。通过将轨道半径视为行星之间的相对步骤,我们可以得出一个看起来不错(但可能不是现实稳定)的行星系统。我们的starDiameter是 16,给我们一个半径为 8 个单位。我们的最内层行星“hg”至少需要 8 + 2 = 10 个单位,以避免与恒星相交;将其放置在 14 个单位似乎很合适。移动到后续行星,通过将每个行星放置在 1.5–1.8 倍的位置,前一个行星的轨道半径将给出看起来不错的结果,而且与我们的太阳系中找到的比率相差不远——这就是您知道它将很有趣的原因!

这就留下了rocky属性。这个标志将通知我们的逻辑,在返回填充的数组之前,需要将不同的纹理集应用到createPlanet函数中的planets数组:


planets.push(createPlanet(hg, scene));
planets.push(createPlanet(aphro, scene));
planets.push(createPlanet(tellus, scene));
planets.push(createPlanet(ares, scene));
planets.push(createPlanet(zeus, scene));
return planets;

显示我们的行星系统所需的最终子任务是实现createPlanet函数。在这个方法中,我们执行以下操作:

  1. 使用MeshBuilder创建一个新的Mesh球体。

  2. 为传入的Color3值创建一个新的diffuseColorspecularColor

  3. 根据rocky标志的值分配纹理。

  4. 将材质分配给网格。

  5. 根据传入的scaleposRadiansposRadius值缩放和定位planet

这可能一开始并不明显,但我们也将材料的specularPower设置为零。这是因为否则我们会在我们的行星上得到非常闪亮的斑点,使它们看起来更像台球而不是岩石或气态球体。对于岩石行星,我们正在引入bumpTexture(即来自 BJS 纹理库diffuseTexture。对于没有可见表面的行星,我们使用扭曲纹理来在大气中添加云带的外观:


function createPlanet(opts, scene) {
    let planet = BABYLON.MeshBuilder.
        CreateSphere(opts.name, { diameter: 1 }, scene);
    let mat = new BABYLON.StandardMaterial(planet.
        name + "-mat", scene);
    mat.diffuseColor = mat.specularColor = opts.color;
    mat.specularPower = 0;
    if (opts.rocky === true) {
        mat.bumpTexture = new
            BABYLON.Texture("textures/rockn.png", scene);
        mat.diffuseTexture = new
            BABYLON.Texture("textures/rock.png", scene);
    }
    else {
        mat.diffuseTexture = new BABYLON.Texture
            ("textures/distortion.png", scene);
    }
    planet.material = mat;
    planet.scaling.setAll(opts.scale);
    planet.position.x = opts.posRadius *
        Math.sin(opts.posRadians);
    planet.position.z = opts.posRadius *
        Math.cos(opts.posRadians);
    return planet;
}

在这段代码到位后,你应该能够运行场景,并获得一个非常好的结果,显示我们的中心恒星和四个不同大小和颜色的行星,它们与恒星的距离各不相同。

![Figure 2.4 – Star system with planets and a skybox

![Figure 2.04_B17266.jpg]

图 2.4 – 带有行星和天空盒的星系

保存这个片段并系好安全带,因为接下来,我们将学习两种不同的方法和风格来使我们的行星移动。

动画轨道

BJS 有许多不同的方式来完成任何给定的任务;在场景中动画化对象也不例外。在 BJS 中动画化的不同方法包括以下几种:

  • 定义一个可重用的BABYLON.Animation对象,它将在一个关键帧数组之间插值指定的属性。

  • 从文件中导入预构建的动画 – BABYLONGLTFGLBOBJ等。

  • 使用OnPreRenderObservable在每一帧渲染之前更新对象属性(例如,位置、旋转、颜色等)。

对于我们的标题屏幕动画,我们将使用第一种和第三种方法来分别动画化我们的小小太阳系的旋转和圆形轨道。在后面的章节中,我们将看到第二种方法的应用。

给星星和行星加上旋转效果

星星和行星的旋转相当简单,但它可以作为对关键帧动画原理和实践的良好复习。由于动画可以循环或循环播放,对于给定的动画通常不需要大量的帧。我们将遵循几个简单的步骤来添加一个createSpinAnimation函数,该函数返回一个新的Animation实例。

首先,我们确定动画目标将改变哪些属性。在这种情况下,只是目标节点的rotation.y值。我们可以说我们的动画应该在 2 秒内完成一个完整的圆(360 度或 2 * Pi 弧度)。接下来,确定动画应包含的总帧数,即Scalar.TwoPi的数量。这就是我们需要实现创建和设置动画属性的代码:


function createSpinAnimation() {
    let orbitAnim = new BABYLON.Animation("planetspin",
        "rotation.y", 30,
        BABYLON.Animation.ANIMATIONTYPE_FLOAT,
        BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
    const keyFrames = [];
    keyFrames.push({
        frame: 0,
        value: 0
    });
    keyFrames.push({
        frame: 60,
        value: BABYLON.Scalar.TwoPi
    });
    orbitAnim.setKeys(keyFrames);
    return orbitAnim;
}

createSpinAnimation方法从createStartScene中被调用,以便将spinAnim变量提供给场景的其他控制代码。

动画创建完成后,可以将其添加到一个或多个不同的mesh.animations数组中。这将把动画附加到特定的Animation对象上,该对象没有start函数或等效的函数。这是因为动画本身对其目标是无知的,允许它在任意数量的不同网格上使用。从star开始,然后遍历我们的planets数组,我们将spinAnim添加到每个网格:


let spinAnim = createSpinAnimation();
star.animations.push(spinAnim);
scene.beginAnimation(star, 0, 60, true);

要开始动画,你需要调用scene.beginAnimation函数,传递起始帧、结束帧、速度参数以及动画对象。我们希望它循环播放,所以我们将true作为最后一个参数传递给该方法:


planets.forEach(p => {
    p.animations.push(spinAnim);
      scene.beginAnimation(p, 0, 60, true,
          BABYLON.Scalar.RandomRange(0.1, 3));
});

当场景运行时,动画会自动开始,你可以观察到所有天体的旋转。

制作轨道运动

scene.onBeforeRenderObservable不同。在游戏引擎循环的上下文中,这是更新逻辑发生的地方。在createPlanet的末尾,我们将添加代码来附加事件监听器,以及跟踪行星轨道参数的附加数据:


planet.orbitOptions = opts;
planet.orbitAnimationObserver = 
    createAndStartOrbitAnimation(planet, scene);

我们的createAndStartOrbitAnimation方法需要推导出许多值。其中两个,轨道半径(posRadius)和角位置(posRadians),被添加到planet作为orbitOptions属性。

周期轨道是行星完成一次完整公转所需的时间(360 度或 2 * Pi 弧度)并且以秒为单位。我们希望每个行星都有一个不同的周期,远处的天体完成轨道所需的时间比近处的天体长,但我们不想不厌其烦地调整值直到它们看起来不错。物理学——或者更具体地说,牛顿力学——为我们提供了计算行星轨道速度的方程,给定其与给定大质量体的距离(半径)。知道位置随时间的变化率,可以计算出角速度:


function createAndStartOrbitAnimation(planet, scene) {
    const Gm = 6672.59 * 0.07;
    const opts = planet.orbitOptions;
    const rCubed = Math.pow(opts.posRadius, 3);
    const period = BABYLON.Scalar.TwoPi * Math.sqrt
        (rCubed / Gm);
    const v = Math.sqrt(Gm / opts.posRadius);
    const w = v / period;
    const circum = Scalar.TwoPi * opts.posRadius;
    let angPos = opts.posRadians;

Gm常数是任意选择的,以确保随着半径的变化,轨道速度有一个平滑的分布。所需的状态变量是angPos,它每帧通过w增加,并通过调用Scalar.Repeat语句保持在有效范围内。一般来说,将这些运动学的角分量视为计数器或钟表指针是有用的;随着时间的推移,通过角速度增加角位置并计算位置分量来完成逻辑:


let preRenderObsv = scene.onBeforeRenderObservable.add(sc => {
    planet.position.x = opts.posRadius * Math.sin(angPos);
    planet.position.z = opts.posRadius * Math.cos(angPos);
    angPos = BABYLON.Scalar.Repeat(angPos + w,
        BABYLON.Scalar.TwoPi);
});
return preRenderObsv;

返回preRenderObsv对象不是使这工作所必需的,但这是一个好习惯,这样我们就可以在不再需要时干净地处置观察者。现在,当场景运行时,行星都以独特的方式围绕太阳旋转。这一切看起来都很棒,但在我们继续之前,我们还可以做最后一件事来真正让场景变得生动。按保存并继续到最后一部分。

轨迹线

为了完成这个动画,我们将使用createAndStartOrbitAnimation方法来为每个行星的轨道添加线条,这是一个做这件事的好地方。我们声明我们的TrailMesh,并将其传递给planet以附加,同时指定路径带的轨道(长度)周长,并指定我们希望路径立即开始。同时,我们还创建了一个新的材质,并将其与路径网格关联:


planet.computeWorldMatrix(true);
let planetTrail = new BABYLON.TrailMesh(planet.name + 
    "-trail", planet, scene, .1, circum, true);
let trailMat = new BABYLON.StandardMaterial
    (planetTrail.name + "-mat", scene);
trailMat.emissiveColor = trailMat.specularColor =
    trailMat.diffuseColor = opts.color;
planetTrail.material = trailMat;

在添加路径网格之前,我们需要强制重新计算行星的世界矩阵;否则,路径将从起源到行星位置出现伪影。就是这样!随着它们的移动,轨道描绘出漂亮的圆圈,但场景仍然感觉有点暗淡。

使用 GlowLayer 发光层增强效果

默认情况下,BJS 不会将材质的发射色通道添加到光照计算中 – 发射纹理和颜色不会使场景变亮。使物体发光很容易;只需将此行添加到createStartScene方法中:


let glowLayer = new BABYLON.GlowLayer("glowLayer", scene);

除非另有说明,否则 GlowLayer 将对场景中的每个网格产生影响。我们不希望行星发光,所以当我们遍历行星以动画化它们的旋转时,将行星添加到 GlowLayer 的网格排除列表中:


planets.forEach(p => {
        glowLayer.addExcludedMesh(p);
        p.animations.push(spinAnim);
        scene.beginAnimation(p, 0, 60, true,
            BABYLON.Scalar.RandomRange(0.1, 3));
    });

点击运行来查看结果。如果你对结果不满意,你可以调整相机的海拔和角度(分别为betaalpha),距离等。无论如何,请确保保存代码片段并享受你的劳动成果。一旦你欣赏完你的作品,请将你的代码片段发布在github.com/jelster/space-truckers/discussions/21上的板上,在那里你可以查看其他人的创作,分享和讨论 – 但别忘了回来,还有更多工作要做!

图 2.5 – 带有 GlowLayer 和轨迹网格的完成轨道动画

图 2.5 – 带有 GlowLayer 和轨迹网格的完成轨道动画

扩展主题

完成的代码片段满足了我们应用的即时需求,但这并不意味着没有改进它的方法!以下是一些你可能自己尝试的想法,这些想法可以增强场景。通过在 Space-Truckers 讨论板(github.com/jelster/space-truckers/discussions)或 BJS 论坛(forum.babylonjs.com/)上发布和分享你的代码片段来加入 BJS 和 Space-Truckers 社区。讨论板和论坛不仅是为了分享你的成就,它们还是一个你可以发布你遇到的问题或问题的场所,有一个热爱帮助的活跃社区。

你可以执行以下操作:

  • 移除行星轨迹的螺旋状外观。旋转动画和轨迹网格都归行星所有。当行星旋转时,轨迹网格会被扭曲。解决这个问题的一个方法可能是向场景中添加一个TransformNode,并将行星归它所有。保持行星上的旋转动画,但将TrailMesh和轨道动画关联并指向TransformNode

  • 用粒子系统替换恒星当前纹理。ParticleHelper有一个太阳效果,可以为场景带来酷炫的效果。有关此内容的文档在doc.babylonjs.com/divingDeeper/particles/particle_system/particleHelper,其中还提供了有关如何创建自定义ParticleSets的有用信息。创建自定义粒子系统的最简单(也许是最好的)选项是使用粒子模式的 NME (nme.babylonjs.com/)。NME 对于着色器来说就像 PG 对于场景一样,这意味着就像你可以保存和分享 PG 片段一样,你也可以保存和分享 NME 片段。它们在这个上下文中的区别在于,你可以在 PG 中使用 NME 片段,但不能反过来。

  • 在一个倾斜轨道上添加一颗彗星,当它在其椭圆轨道通过场景中的恒星时,它会变得更亮并显示出尾巴。倾斜仅仅意味着物体包含 y 轴,当它“上下摆动”穿过轨道平面时。椭圆轨道具有与圆形轨道相同的周期,轨道半径与椭圆的半长轴(沿着椭圆长边分割椭圆的线的长度)相同,但不同之处在于,沿着其路径移动的物体在椭圆轨道上以最接近点(近点)的速度最快。

  • 给最外层的气态巨行星添加一个环系。一种方法是使用 MeshBuilder 创建一个平面环状网格,然后使用 BJS 的父子关系将环系附着到行星上。另一种基于前者的方法是使用固体粒子系统SPS)生成数十甚至数百个小岩石来构成环系。这可以看作是即将到来的预览:在下一章中,我们将使用 SPS 来创建一个小行星带。

  • 向一个多岩石的行星添加云的镜面反射、地形凹凸(法线)图,使其看起来像真实世界的行星。BJS 的资产库包含了地球地形的等高图以及各种云和地面效果的纹理。材质库也有一些有趣的选项可以探索,以使行星独特且吸引人——享受乐趣!

  • 让摄像机以电影般的方式在系统中平移和缩放。选择我们之前讨论过的动画方法之一,针对场景的camera。根据你的方法和计划,你可能想要取消或更改摄像机的目标为TransformNode。这个新的、不可渲染的节点充当一种“标记”,可以移动以改变摄像机视图,从而改变位置。另一个选项是探索不同于当前ArcRotateCamera的摄像机类型。

摘要

在本章的整个过程中,我们通过在 PG 中创建一个简单的场景来刷新和提升我们对 BJS 的了解。我们学习了如何以不同的方式动画化场景,以及如何从 BJS 资产库中加载纹理和其他资产。希望我们在旅途中有点乐趣,但这只是冰山一角,关于后续章节中将要介绍的内容。如果你需要一点 BJS 的复习,希望这已经让你热身并准备好继续前进。如果你是 BJS 的新手,那么希望这已经赋予你继续前进到下一章的信心。在下一章中,我们将认真开始 Space-Truckers,通过设置本地开发环境、源代码控制和调试来着手进行。

进一步阅读

BJS 文档网站包含大量的知识和内容。以下是文档中一些相关的页面,它们更详细地介绍了本章中涉及的主题:

  • 一旦你学会了如何进行单个动画,请阅读有关动画序列、分组和组合的内容,从这里开始。

  • 了解如何将不同类型的资产文件导入场景,以及加载器的工作原理,请参阅这里

  • 深入探索:在Mesh 部分有关于 GlowLayer 如何工作的详细信息。

  • 关于不同类型相机及其属性的详细信息,请参阅这里。值得一提的是,无论你在文档中看到提到FreeCameraTouchCameraGamepadCamera,你应该用UniversalCamera来替换或使用,因为它取代了这三个,保留它们是为了向后兼容的原因。

第三章:建立开发工作流程

虽然Babylon.js 游乐场PG)是一个非常灵活且强大的工具,用于开发、运行和共享 3D 渲染场景,但它也在传统 Web 应用程序的开发工作流程中占有一席之地。通过消除摩擦,有效地实现了有效的软件开发。这里的摩擦是指任何在编写代码和执行结果之间设置障碍的东西,它几乎可以采取任何形式,从平凡到神秘。例如,假设在代码更改后到更改的代码在开发者的 Web 浏览器中运行之间需要一个小时。那么开发者将被迫在每次新的构建中包含尽可能多的内容,这使得理解任何单个更改对应用程序行为的影响变得更加困难。在这种情况下,注意力会被稀释,进步是渐进的,并不与所需的努力成比例,这就是为什么对开发工作流程的小幅调整可以带来巨大的收益。在本章中,我们将探讨许多潜在Babylon.js开发工作流程中的一个,到本章结束时,您将拥有快速高效构建游戏所需的工具,这些游戏可以像您思考设计一样快速地发展!

每个人都会有不同的方法来接近开发的结构和流程,这是完全可以接受的。以下每个部分都展示了工作流程的一个方面,旨在最大化开发效率和质量,同时最小化技术债务和不确定性:

  • 设置环境

  • 构建游乐场片段

  • 从游乐场过渡到应用程序

  • 构建着陆页

技术要求

运行 BJS PG 的基本要求在第二章“提高 Babylon.js 技能”中详细说明,但除了这些要求之外,还有一些我们将要使用的额外开发工具。

重要提示

尽管示例等都是基于基于 Windows 的开发者体验,但遵循本书没有操作系统要求。所有讨论的工具都可在多个平台上使用,并且将在可行的地方突出显示或指出语法或用法的差异。

每个单独项目的具体用法将在随附的章节材料中介绍,并假设您对工具及其用法有一定的了解。有关设置和配置特定工具的信息,请参阅该工具文档的相应链接。

  • Visual Studio Code是我们首选的 IDE,可在所有平台上使用,工作出色,且免费:code.visualstudio.com

  • Node.js v14.15.4 (LTS)或更高版本:docs.npmjs.com/

  • Node 包管理器 (npm) CLI v6.x(LTS 版本) 或更高版本,通过在 docs.npmjs.com/cli/v6/configuring-npm/install 列出的节点版本管理器安装。

  • Git 版本控制客户端。此外,为了能够提交 Pull Requests、提交 问题 或参与 讨论,需要一个有效的 GitHub 账户:github.com

TypeScript 用户注意事项

如果您更喜欢使用 TypeScript 而不是纯 JavaScript 进行所有编码,那很好!Babylon.js 本身是用 TypeScript 编写的,并且完全支持在 BJS 中开发。跟随这本书中的代码是可能的,并且语法和结构的不同并不总是会被解释或指出。话虽如此,考虑到以下两个主要变化,代码在这两种语言之间应该具有很大的兼容性:

  1. 游戏场代码片段应使用 TypeScript 模式。这有一个稍微不同的模板。从 www.babylonjs-playground.com/ts.xhtml# 开始,点击 createScene 方法被封装为 Playground。声明新的类并在 createScene 方法中使用它们,就像在常规 JavaScript 中使用一样。

  2. 当将使用 PG 编写的类进行集成时,非常重要的一点是要添加 export class Foo { //… })。由于您将使用 tscTypeScript 编译器)来输出 JavaScript,有时您将需要 导入某些 Babylon.js 模块以利用它们的 副作用。有关如何为 Babylon.js 配置 TypeScript 的更多信息,请参阅 doc.babylonjs.com/divingDeeper/developWithBjs/npmSupport#typescript-support

设置环境

有效的软件开发依赖于能够自信地向应用程序的结构中引入更改。引入、更改或删除代码的信心来自于 a) 能够使用新的更改运行代码,以及 b) 不处于撤销更改会带来自身风险的境地。让我们暂时放下这个想法,回过头来从头开始。

准备步骤

进入这一步的 先验 假设是您已经设置了 GitVSCodeNode.jsNPM,并且它们都准备就绪。还建议使用像 ESLint 这样的代码检查工具。如果您知道自己在做什么,现在就可以开始设置和配置这些工具。不用着急,这只是这本书的其余部分在等待——如果你在哼唱 伊帕内玛的女孩 的同时工作,这可能会更快。VSCode 拥有一个丰富的扩展生态系统,可以使您的生活更加轻松。以下是一些您可能想要安装的扩展(或它们的等效项)的列表。转到 VSCode 中的 扩展 面板,然后搜索适当项目的 Marketplace ID

图 3.1 – 有用的 VSCode 扩展列表

图 3.1 – 有用的 VSCode 扩展列表

如果你还没有完全了解在哪里以及如何做这类事情,这里有一些你可以做的事情。忽略电梯音乐,在额头绑上头巾,然后直接进入 80 年代电影蒙太奇序列。你可能首先想要在这个页面上设置一个书签——蒙太奇包括一系列的特写镜头,你将翻到本章末尾的 进一步阅读 部分,阅读并跟随链接,最终成功安装……然后翻回你的书签,准备继续旅程。

初始化所有事物

这里有一些不起眼的任务在进行中——比如在 GitHub 中创建一个新的 Git 仓库并在本地克隆它,这些细节过于详细,不适合在此展开。相反,这里是一个粗略的清单,列出在这个步骤中你预期要执行的任务:

  1. 创建一个新的 Git 仓库。如果在 GitHub 中创建,可能需要本地 克隆 仓库。

  2. 在仓库中添加一个 .gitignore 文件——目前真正需要的只是输出 dist/ 文件夹和 node_modules/ 文件夹的条目。

  3. 创建一些文件夹——srcdistpublicassets——分别用于存放源代码、打包输出和游戏资源。

  4. 运行 npm init 以创建应用的 package.json 文件。

  5. 使用此命令将 webpack 和核心 Babylon.js 库及其依赖项作为开发者依赖项安装:

    npm install -–save-dev webpack webpack-cli webpack-
      dev-server webpack-merge clean-webpack-plugin file-
      loader html-webpack-plugin source-map-loader url-
      loader eslint `@babylonjs/core 
    
  6. 安装我们将要使用的额外 Babylon.js 模块:

    npm install -save-dev @babylonjs/materials
      @babylonjs/loaders @babylonjs/gui
      @babylonjs/procedural-textures @babylonjs/post-
      processes @babylonjs/serializers
      @babylonjs/inspector
    

在解决好包依赖项之后,是时候为我们的新应用程序添加一些基础组件了。

脚本和 ESLint 配置

在不久的将来,我们将想要能够围绕我们的应用程序的构建和部署任务添加一些自动化。使这个过程尽可能无摩擦的关键是尽可能利用(和类似)的应用程序基础设施。保持简单,将脚本集中在单个任务上,将有助于未来更容易实现自动化。

package.json 脚本

我们希望首先添加到 package.json 文件中的有三个基本命令。这些是简单的脚本,将允许本地和生产的构建以及源代码的代码检查。我们将在下一节中讨论开发与生产构建的区别,但就目前而言,将这些脚本添加到 package.json 文件中:

  • start:webpack 开发服务器和相关打包过程,用于本地开发。命令:npx webpack serve --mode development

  • build:以生产配置运行 webpack。命令:npx webpack --mode production

  • lint:确保我们的代码没有任何大的“糟糕!”。命令:npx eslint

检查您的工作中的错别字,并确保保存并提交您的package.jsonpackage.lock.json文件。到目前为止,我们在设置应用程序的清单中仍然缺少几个项目,所以让我们把它们完成,以便我们可以在我们的旅程上继续前进!

重要提示

虽然将整个 Babylon.js 库简单地引用并加载到应用程序中是可能的,但这样做效率极低——因为 BJS 做了很多,库中有很多内容,这意味着它们在大小和复杂性上相当大。客户端被迫在应用程序能够对输入做出响应之前下载完整的 JS 包,这降低了用户对应用程序性能的感知。减少应用程序足迹的最现代和有效的方法之一是利用ES6的一个功能,称为tree shaking。tree shaking 的过程会产生只包含代码中实际使用的依赖项的代码输出,从而产生更小、更快、更高效的 JavaScript 模块。

有什么缺点吗?正如您将看到的,每个导入的类型都必须有自己的import语句,但除此之外,还必须指定类型的完整路径——而不仅仅是包含的包。尽管如此,好处可能非常显著——正如我在这个 pull request 中评论的那样:github.com/jelster/space-truckers/pull/15。起始场景的大小从 8.91 MB 减少到 3.11 MB,减少了超过 50%!

Babylon.js 存在的时间比 ES6 模块支持的时间更长,团队已经承诺在引擎中支持向后兼容性。这就是为什么您会注意到在某些地方,这种妥协导致需要仅为了副作用而导入模块——MeshBuilder CreateXXXX API 是这一点的突出例子。BJS 文档中有更多信息位于doc.babylonjs.com/divingDeeper/developWithBjs/treeShaking,可以解释更多关于为什么以及哪些模块以这种方式行为的原因。

我们之前查看的 PG 示例在构建方面并没有要求特殊处理,但这是因为 PG 的目标与我们要实现的目标不同。我们正在构建一个完整的应用程序,它不能依赖于 PG 相同的奢侈(例如,使用 CDN 获取 Babylon.js 库)。为了做到这一点,我们将牺牲 PG 灵活但低效的加载所有内容的方法,以换取 webpacked 应用程序的紧凑性和效率。

添加 ESLint 配置

使用 VSCode 在你的仓库根目录中添加一个新文件,命名为 .eslintignore。这是一个文本文件,我们将用它来排除某些目录被 lint 工具检查,从而提高响应性和可靠性。我们不希望检查 node_modules 目录,因为我们没有在这些库上工作。同样,我们也不关心已经打包并输出的 JavaScript 代码——dist/ 文件夹中的任何内容。将以下行添加到刚刚创建的 .eslintignore 文件中:

node_modules
dist

保存并关闭文件。

配置 Webpack

在根目录中添加另一个新文件,命名为 webpack.common.js,然后创建另外两个分别命名为 webpack.dev.jswebpack.prod.js 的文件。我们将基础 Webpack 配置放在 webpack.common.js 文件中,并在脚本运行时使用 webpack-merge 合并特定环境的配置。同时,在 src 中创建一个新的空文件,命名为 index.js,在 public/ 目录中创建一个空的 index.xhtml 文件。这将作为未来工作的占位符,同时允许我们测试和验证当前的配置。

注意

dist/ 文件夹。其他相关资产也可能受到影响,从生成正确的 URL 路径到将标记模板渲染到输出目录等。查看 Webpack 仓库以及 github.com/webpack/webpack 上的文档,了解更多关于配置和插件选项的信息。

开发模式与生产模式

在生产构建上下文中运行时,实际上只有两件事需要发生。首先,Webpack 执行其任务,将 src/ 文件夹中的所有 .js 脚本打包并打包,将结果输出到 dist/ 文件夹。其次,将应用程序的入口点——index.js——的脚本引用注入到 index.xhtml 文件中,这是提供给网络浏览器的文件。

本地开发与生产构建的需求略有不同。我们希望能够尽快看到对代码所做的更改的结果,这排除了在更改后从头开始重新打包所有内容的可能耗时过程。相反,webpack 开发服务器足够智能,既能缓存构建输出,又能选择性地仅重新构建已更改的部分。通过到浏览器的 WebSocket 连接,当新包被编译时,会自动刷新页面,从而进一步缩小迭代过程中的差距。我们还想发出 JavaScript 源映射,以帮助调试以及为开发服务器提供非打包内容的路径。

常见 Webpack 配置

无论 Webpack 是用于开发还是生产使用,我们总是想确保我们的目标目录被清理掉任何旧的或可能过时的源文件。我们将使用 CleanWebpackPlugin 来实现这个目的,并使用 HtmlWebpackPlugin 将适当的脚本引用注入到我们的 index.xhtml 模板中。

回到 webpack.common.js 文件,让我们添加一些 import 语句并定义 module.exports 桩函数:


const path = require("path");
const HtmlWebpackPlugin = require(«html-webpack-plugin»);
const { CleanWebpackPlugin } = require(«clean-webpack-
  plugin");
const appDirectory = __dirname;
module.exports = env => {
    return {
    };
};

你可能会注意到,与我们的应用程序的其他部分不同,我们的 Webpack 配置没有使用指定在客户端启动应用程序的脚本的 entry 对象;它将被注入到网站的默认 index.xhtml 登录页面的 <script> 标签中。

重要提示

在跨平台环境中工作时,文件和文件夹路径可能会很复杂。__dirname Webpack 提供的变量是一个很好的方法来避免问题,因为它将正确且一致地表示 fs.cwd() 的等效值。

entry 项目和可能的其他配置元素在读取和写入文件时需要知道要使用的基本路径,因此我们指定并计算这个值。在此同时,我们也可以将输出条目添加到我们的配置中。该对象指定了打包结果要输出的位置,为了帮助识别潜在的其它脚本,我们将其命名为 babylonBundle.js。最后,我们实例化新的 CleanWebpackPluginHtmlWebpackPlugin 模块。

重要提示

添加到插件数组中的插件顺序很重要!请确保你的 CleanWebpackPlugin 总是位于插件列表的顶部,以便它首先运行。

HtmlWebpackPlugin 被赋予了我们的公开服务 HTML index.xhtml 页面的路径,并被告知将正确的脚本标签注入到文档中。一旦完成,我们将在完成常见的(也是最大的)配置设置之前快速测试我们的配置:


module.exports = {
    const appDirectory = __dirname; 
    return {
        entry: «./src/index.js"),
        output: {
            filename: «js/babylonBundle.js",
            path: path.resolve(appDirectory, "./dist")
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: path.resolve("public/index.xhtml"),
                inject: true
            })
        ]
    };
};

通过指定一个 assetModuleFilename 模式,我们正在指示 assets 子文件夹使用原始文件名、扩展名和任何查询字符串参数。为了测试我们的配置,请确保你已经保存了所有内容,并在终端窗口中输入以下命令(确保你的工作目录与存储库的根目录相同):

npx webpack –config webpack.common.js

如果一切顺利,你应该在命令窗口中看到一些文本,一些绿色文本,并且没有错误。那太好了,但还没有太多的事情发生,所以我们还不能休息——我们离完成这一部分非常接近了!

解析器和加载器配置

作为处理源代码的一部分,Webpack 将编译一个包含所有各种 导入(或对 CommonJS 模块使用 require)的列表,并调用一个使用匹配规则来选择适当逻辑以解析请求位置的处理管道。

注意

这是一个TypeScript用户将看到他们的实现与这个 ES6(类似)实现之间有显著差异的领域。BJS 团队的Raanan Weber已经将一个 TypeScript 启动存储库发布在github.com/RaananW/babylonjs-webpack-es6。这里列出的 TypeScript Webpack 代码被设计得尽可能接近 Raanan 的启动模板,以便在阅读本文和你的代码之间过渡更容易。

为了避免在静态资产 URL 中编码环境差异的需要,我们使用source-map-loader帮助匹配运行时代码中的符号与源代码中的位置。在此之前,我们的配置需要一个resolve对象,该对象指定了一个extensions数组以启用搜索。将此作为返回的配置的属性添加,位于output属性下方。这部分配置可能看起来像这样:


// entry, output, etc…        
        resolve: {
            extensions: [".js"],
            fallback: {
                fs: false,
                path: false,
            },
        },
        module: {
            rules: [  
             {
                test: /\.(png|jpg|gif|env|glb|stl)$/i,
                use: [
                {
                  loader: "url-loader",
                  options: {
                    limit: 8192,
                  },
                 },
               ],
        },
// plugins, etc.

modules属性的rules列表中定义了test在执行以查看给定的加载器是否能够处理请求时,什么构成了一个独立的模块。对于资产/资源模块类型的长的正则表达式本质上是一个列表,列出了我们希望被视为资产的、不需要进一步处理就复制到输出目录的所有不同文件扩展名。

Webpack 开发和生产配置

在我们的webpack.dev.js中,我们将利用webpack-merge插件来扩展 webpack。这个方便的实用工具会将两个 webpack 配置对象合并在一起,并返回合并后的结果。为什么这很方便呢?因为我们将能够拥有独立的开发和生产配置,而无需将它们的名称硬编码到webpack.common.jspackage.json脚本中。如果我们想添加另一个环境配置,我们只需要添加新的 webpack 配置文件,合并我们的通用配置,然后将我们的npx webpack --config参数指向适当的文件。

我们实际上只需要从我们的开发配置中获取两样东西,这些在通用配置中是没有的。首先,使用npx webpack serve启动的 web 服务器的配置。其次,我们指定我们希望我们的源映射与脚本一起内联发送。顶层模式“开发”确保 webpack 不会采取适合生产的各种优化路径。这就是我们完成后的webpack.dev.js的样子:


const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const path = require('path');
const appDirectory = __dirname;
const devConfig = {
    mode: "development",
    devtool: "inline-source-map",
    devServer:  {
        contentBase: path.resolve(appDirectory, "public"),
        compress: true,
        hot: true,
        open: true,
        publicPath: "/"
    }
};
module.exports = merge(common, devConfig);

创建webpack.prod.js甚至更简单,因为我们不需要开发服务器配置,并且它与我们的开发配置共享相同的顶层require语句集合。为了减小我们的脚本包的大小,我们将选择不输出源映射,并且除了将模式设置为生产之外,这就是唯一的区别:


const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const prodConfig = {
    mode: "production"
};
module.exports = merge(common, prodConfig);

在我们稍微转移一下焦点之前,让我们将一些标记放入public/index.xhtml文件中。现在我们不需要太多,所以让我们从以下简单的标记开始:


<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Space-Truckers: The Video Game!</title>
    <style>
      html,
      body {
        overflow: hidden;
        width: 100%;
        height: 100%;
        margin: 0;
        padding: 0;
      }
      canvas {
        width: 100%;
        height: 100%;
        touch-action: none;
      }
    </style>
  </head>
  <body>
  </body>
</html>

这就足够我们通过确保在运行npm run start之前所有文件都已保存来检查我们的进度。成功将通过启动你的网络浏览器和类似以下截图的控制台输出指示:

图 3.2 – 成功打包后的 Webpack 输出

图 3.2 – 成功打包后的 Webpack 输出

当 Webpack 开发服务器运行时,你对源代码所做的任何更改都将自动刷新你的浏览器。请保持开发服务器运行,因为我们将要开始利用它!

制作 PG 代码片段

在我们能够在我们的应用程序中使用我们的 PG 代码之前,我们需要做一些轻微的重构。一点准备可以节省以后大量的时间!我们将要更改的是可能在不同 PG 和本地环境中变化的代码片段,例如纹理路径和 URL,以及一些小的结构修改。为了方便起见,这里有一个链接到重构后的代码片段。如果你是刚刚加入我们的旅程,请使用下面的链接。如果你一直在跟随,请用你自己的代码片段 URL 替换以下一个。首先,打开你喜欢的浏览器,导航到你的代码片段或到playground.babylonjs.com/#0UYAPE#42

清理 BABYLON 命名空间前缀

在 PG 中编码时,你可能发现了一件令人烦恼的事情,那就是在 PG 中总是需要在 BJS 类型前加上BABYLON命名空间。这并不理想,但我们可以通过在我们的代码片段顶部添加所有各种类型的别名来消除对这些命名空间的需求。在我们的 PG 代码片段中,别名将被定义为从各种 BJS 类型组成的const


const { Mesh,
MeshBuilder,
StandardMaterial, 
// ...
} = BABYLON; 

然后,我们可以进行查找和替换(Ctrl + FCommand + F)字符串BABYLON.(不要忘记句号!)来完成本节的修改工作。为了预览这一进展,当我们将其移动到我们的 VSCode 环境中时,我们将将其转换为import语句。像我们现在这样事后进行重构并不是最佳做法;在未来,我们将从这种结构开始编写代码片段,并随着时间的推移逐步构建。这样,它将不会花费太多的精力!

提取魔法字符串

在我们的代码片段中使用了三个单独的纹理(不包括程序化纹理),我们希望使其更容易更改特定的 URL 或文件路径。我们首先通过在 PG 中定义一组const字符串来包含 PG 特定的路径:


const distortTexture = "textures/distortion.png";
const rockTextureN = "textures/rockn.png";
const rockTexture = "textures/rock.png";

然后,我们可以进入createStarcreatePlanet函数,并将硬编码的路径替换为我们的常量表达式:


mat.diffuseTexture = new Texture(distortTexture, scene);

一旦替换了所有硬编码的字符串值,点击保存并刷新页面,以确保片段仍然可以正常运行,注意任何缺失的纹理,并修复可能出现的任何缺失引用。有了这些更改,从在 PG 中运行到在我们的应用程序中使用它将是一个平稳的过渡。

从 PG 过渡到应用程序

PG 是一个丰富、健壮且可扩展的方式,可以快速开始编写和运行代码,但我们的应用程序与 PG 有不同的需求,我们需要考虑并满足这些需求。我们希望确保我们的代码既易于更改又易于理解,但幸运的是,我们可以采取一些小步骤,这些步骤将在以后产生重大影响。

创建引擎实例

现在,最直接的问题就是:我们如何将这里的勇敢片段插入到我们的应用程序中,而不会让它变成一种既是自虐又是自律的练习?秘密在于准备。当我们构建 PG 片段时,我们将逻辑尽可能原子化地结构到各种离散的index.js中,这些文件将取代 PG 的engine初始化。将以下内容添加到创建canvas元素的部分下方:


let eng = new Engine(canvas, true, null, true);
let startScene = createStartScene(eng);
eng.runRenderLoop(() => {
    startScene.scene.render();
});

这是一个相当标准的 Babylon.js Engine初始化。Engine构造函数有许多有趣的参数和配置选项,我们将在稍后进一步探讨。现在,我们主要使用引擎的默认设置,除了为了期待其即将到来而启用createStartScene

添加和导入起始场景

在你的项目src文件夹中创建一个新文件,命名为startscene.js。将 PG 片段中的所有内容复制并粘贴到这个新文件中,除了 createScene 函数。由于我们之前已经打下了基础,所以只需要进行一些小的修改!

const改为import,也将=替换为from “@babylonjs/core”作为源导入的名称。StarfieldProceduralTexture不是核心 BJS 框架的一部分,因此我们还需要将此条目从导入列表中拉出,并为其提供一个单独的条目:import { StarfieldProceduralTexture } from “@babylonjs/procedural-textures”;。

最后的修改是将我们的const纹理路径替换为指向/assets/textures文件夹中适当纹理的import语句。

重要提示

如果你还没有下载三个纹理并将它们放在资产目录中,现在是一个好时机。纹理的 URL 前缀是www.babylonjs-playground.com/textures/,后面跟着纹理的名称和扩展名(例如,rock.png)。我们希望能够在整个应用程序中使用一致的路径来引用资源,因此我们使用import语句。

为什么我们不直接使用资源的在线版本,而不是在本地复制它?这是个好问题。在本书的后面部分,我们将介绍如何将 Space-Truckers 变成一个渐进式 Web 应用PWA),以及如何使资产可用于离线使用。

import语句包含在构建输出中时。此外,资产被分配了一个唯一的文件名,这有助于在修改资产时打破激进的缓存:


import distortTexture from
  "../assets/textures/distortion.png";
import rockTextureN from "../assets/textures/rockn.png";
import rockTexture from "../assets/textures/rock.png";

导出和导入起始场景

在我们的startscene.js中添加最后一项,我们就可以准备好将其连接到游戏中了!如果我们回顾一下我们代码片段函数的整体设计,我们可以很容易地看出,唯一需要的“公共”函数就是createStartScene函数。让我们通过在函数声明中添加export default来使这个函数对消费者可用:


export default function createStartScene(engine) {

保存文件并切换回你的index.js。由于我们已经在文件导入列表的顶部添加了对createStartScene的调用和随后的importimport createStartScene from “./startscene”;。保存文件并检查Webpack 输出是否包含任何错误。当你的浏览器刷新时,你应该会看到一个熟悉的场景被渲染出来。向前迈进,给自己鼓掌吧——你已经完成了将我们的主要应用程序背景场景引入进来!然而,还有一些东西仍然缺失,那就是当访客首次到达网页但尚未启动游戏时可以看到的东西。如果我们不先询问就接管访客的浏览器并开始下载 MBs 的内容,那就有些失礼了,所以我们将推出一个欢迎垫,即着陆 HTML 页面。

构建着陆页

尽管它是基于 Web 并由 Web 服务器托管,但 Space-Truckers 中有一个关键原则在起作用:这是我们之前没有做很多但只是暗示过的游戏。这个原则是我们无论如何都要避免在游戏中使用 HTML DOM。现在,为了公平起见,这并不是对在任何地方使用 HTML 或 CSS 的完全禁止,只是在任何重要的地方。这样做的原因是我们想给未来的自己一个礼物,使得将 Space-Truckers 定位到 Babylon Native 变得无缝;使用 HTML DOM 的代码与 BJS Native 不兼容。话虽如此,我们仍然需要做一些 HTML 和 CSS 的工作,使着陆页对访客更加友好。

概念(艺术)

当 Space-Truckers 还处于构思阶段时,早期的概念草图有助于确立游戏的外观、感觉和设置的各种不同方面。以下图展示了我们希望我们的着陆页看起来像什么:

图 3.3 – HTML 着陆页设计

图 3.3 – HTML 着陆页设计

当用户导航到 Space-Truckers 网站时,他们会看到一个居中的图片,其功能类似于书籍封面试图传达书籍内容的方式。一个用于启动游戏的行动号召按钮显著且清晰地位于视口的中心,吸引访客点击按钮并玩游戏。最后,我们还有一个包含标准隐私政策、支持、仓库、许可、版权声明等的小型网站页脚。

注意

我们希望我们的标记结构能够在从高 dpi(但屏幕尺寸小)的手机或平板电脑到大屏幕电视和显示器提供的更大但分辨率较低的屏幕尺寸范围内适当地显示。纵横比也同样重要!

稳稳着陆

如果一切顺利,我们的着陆页面将类似于这个。我们现在不会太在意字体或背景图片,我们更想了解我们想要如何布局和设计各种元素。

![图 3.4 – Space-Truckers 着陆页面。前景内容之后是第二章中创建的动画绕行行星,这些行星是在《在 Babylon.js 中提升》这一章节中制作的img/Figure_3.04_B17866.jpg

图 3.4 – Space-Truckers 着陆页面。前景内容之后是第二章中创建的动画绕行行星,这些行星是在《在 Babylon.js 中提升》这一章节中制作的。

要实现这一点,需要在/public/index.xhtml页面添加一些 HTML 标记和 CSS 样式。我们还需要对index.js文件进行一些额外的修改,将background-canvas类添加到新创建的 HTML Canvas 中,该 Canvas 是通过canvas.classList.add(background-canvas);附加到文档的,所以先完成这个修改,然后在 VSCode 中打开public/index.xhtml文件。需要添加的内容很多,可能会占用过多的页面空间,所以在这个阶段,你有几个选择:

没有正确或错误答案;最重要的是你能在你拥有的时间内享受并从中学到最多的东西!这本书的每一章在 Git 中都有一个相应的分支(和标签)。保留整个分支及其提交历史的目的,是给你一个机会看到代码是如何逐个提交地演变的,同时避免向主分支的提交历史中添加过多的噪音。

摘要

在 Webpack、ES6 导入和 CSS 恶作剧的狂潮中,我们完成了一个关键过程,它从简单的 PG 片段开始,以一个动画着陆页结束。在这个过程中,我们设置了我们的本地开发脚本,以便我们可以利用现代 JavaScript 功能,如 tree-shaking 来优化我们的包捆绑大小,同时仍然能够快速集成和查看应用程序中的更改。

从这里接下来应该对任何曾经站在标有“启动”字样的大红按钮前的人来说相当明显。是时候按按钮了,让它做些有趣的事情!是的,我们将实现我们的应用程序的启动时体验,这涉及到在应用程序中建立一些状态机制。如果你还没有完成这一部分,别担心,还有更多要做!

扩展主题

对于想要将启动页面个性化或想要深入了解本章开启的潜在可能性的个人,以下是一些你可能考虑做的事情:

  • 为启动按钮添加一个酷炫的悬停进/出效果,当光标悬停在它上面时,应用颜色和/或动画效果。点击按钮时也做同样的事情。

  • 通过链接到 GitHub 仓库等,改善着陆页的导航结构。

  • 将中心英雄区域变成一个可以填充额外概念艺术、截图、游戏视频等的图片轮播。

  • 使用 CSS 以有趣的方式将画布动画与英雄图像混合。你可以做不同类型的混合,如差异、排除、屏幕等,以及其他酷炫的转换。

第四章:创建应用程序

Space-Truckers 应用程序需要能够维护和在不同屏幕之间切换一组离散状态,例如菜单屏幕和游戏屏幕。应用程序状态之间的转换通常是由于用户交互(例如,用户选择菜单项)或作为某些操作(如应用程序启动或退出)的一部分而发生的。在这里,我们推导出基本的应用程序流程,然后使用它来构建一个基本框架,用于呈现和切换任意屏幕。

在第一章中,我们看到了 Space-Truckers 游戏的全部辉煌和美丽。然后我们立即在沙盒中创建加载屏幕的动画,然后放慢速度来构建游戏所需的支持应用程序基础设施。我们可能觉得我们一直专注于游戏设计之外的事情,自然地想要专注于诸如引入 3D 模型和纹理或编程游戏机制等活动。不用担心——我们将在不久的将来达到那里!第二部分:构建游戏正是关于这些话题,但没有本章和随附章节的工作,就没有东西可以连接一个主题相关的有趣沙盒片段和代码片段集合。

重要提示

本章将代表代码和内容呈现方式的更大转变。从现在开始,代码列表将倾向于显示代码片段或突出显示更大代码块中的有趣区域。始终会提供链接到仓库或沙盒,以便您可以检查您的作品或使用代码跳过前进!

本节和本章的工作是构建必要的软件和逻辑组件,以使从单个组件中产生一个统一且引人入胜的体验。在本章的整个过程中,我们将编写代码以实现状态管理和转换逻辑,以支持在这些标题下核心游戏机制的未来开发:

  • 添加自定义加载用户界面

  • Space-Truckers:状态机

  • Space-Truckers:主菜单

  • 集成主菜单

技术要求

对于本章,我们将继续使用在第三章中介绍的开发流程,即建立开发工作流程。如果你是刚刚加入我们的旅程或者还没有自己编写代码,你可以通过克隆或检出 Space-Truckers:GitHub 仓库中的ch3-final标签来赶上进度:github.com/jelster/space-truckers/tree/ch3-final。在为本章的材料编写任何代码之前,通常一个好的做法是创建一个新的git分支来跟踪上一章的分支标签。这很不寻常,因为你通常会设置你的分支来跟踪developmain。然而,在这种情况下,你想要比较的是在开始之前,从存储库的提交历史中特定点的提交,而不是之后的所有内容都已被涵盖。

添加自定义加载用户界面

当我们开始获得一些动力和势头时,我们首先必须让我们的引擎预热,然后才能考虑换挡。进行一次简短的代码管理练习正是提高这些转速的方法!一旦我们找到了最佳点,我们就会直接利用这项工作来构建我们的加载界面。记住,当我们通过复杂性的比喻变速箱前进时,我们将看到更少的细节,例如以下内容,同时也会覆盖更大的范围。

单独练习:重构 StartScene 以提取 AstroFactory

为了为这个和未来的某些功能打下基础,我们希望从startScene中提取所有与创建新行星相关的逻辑,这些新行星不是特定于场景的。这个逻辑被放入一个新的astroFactory类中。这次重构的基本要素很简单,但关键在于创建一个行星数据对象的数组,然后遍历这个数组,调用AstroFactory的各种方法来组合场景的对象。将这次重构视为一种特殊的练习或挑战,但不要过于担心。目的是尝试应用新的知识,而不是给予通过或失败的评价!或者,如果你觉得不需要练习或想跳过这个练习,可以从检查以下提交 URL 的补丁差异开始编写代码:github.com/jelster/space-truckers/commit/9821811。花时间理解材料,但别忘了回来继续本章和整本书的内容!

自定义加载界面类型

Babylon.js 提供了一个默认的加载 UI,在engine.displayLoadingUI()方法调用时自动出现。无论以何种方式调用,我们都会用我们自己设计的 UI 替换默认的加载 UI。Babylon.js 文档指定了displayLoadingUI()hideLoadingUI()的详细信息——这些看起来熟悉吗?向项目的源代码中添加一个新的 JS 文件,并将其命名为createStartScene函数,来自我们老朋友**startscene.js**

engine实例(createStartScene方法所需的)。在构造函数中,我们将初始化并分配一些类级别的属性供以后使用——包括_startScene


constructor(engine) {
    this._totalToLoad = 0.00;
    this._loadingText = "Loading Space-Truckers: The Video 
        Game...";
    this._currentAmountLoaded = 0.00;
    this._engine = engine;
    this._startScene = createStartScene(engine);
}

这样就完成了加载界面的构建。现在,我们需要实现LoadingScreen接口的成员,以便在适当的时候显示和隐藏 UI。这仅仅是通过使用显示和隐藏方法切换一个_active布尔标志来完成的;我们将让不久后我们将编写的其他代码来决定如何处理它:


displayLoadingUI() {
    this._active = true;        
}
hideLoadingUI() {
    this._active = false;   
}

最后需要做的是有条件地渲染场景。由于我们在构造函数中传入了引擎实例,我们将在构造函数的末尾添加一个简单的渲染例程来调用runRenderLoop


 engine.runRenderLoop(() => {
    if (this._startScene && this._active === true) {
        this._startScene.scene.render();
    }
});

我们已经完成了大部分工作,但在我们可以称之为完成的任务之前,还有一些事情要做。

通过进度显示增强加载界面

我们添加了一个所谓的非确定性进度条,但如果我们想显示一些文本以及已加载资源的百分比呢?尽管我们的项目还没有这些资源,但很快就会有。幸运的是,为了支持这一点,我们只需要做几件小事。

添加属性 getter

loadingUIText;它可能会被外部代码调用或查询。不过,既然我们在做这件事,让我们添加以下额外的 getter:


get progressAvailable() {
    return this._progressAvailable;
}
get currentAmountLoaded() {
    return this._currentAmountLoaded;
}
get totalToLoad() {
    return this._totalToLoad;
}
get loadingUIText() {
    return this._loadingText;
}

一个敏锐的眼睛可能会注意到progressAvailablegetter 使用了一个在构造函数中没有定义的字段。这个设置和管理的地方与currentAmountLoadedtotalToLoad获取其值的地方相同——onProgressHandler函数。

处理进度

onProgressHandler是一个事件处理程序,它订阅了由 Babylon.js 组件(如AssetManagerSceneLoader)发出的 HTTP 和其他进度事件:


onProgressHandler(evt) {
    this._progressAvailable = evt.lengthComputable === true;
    this._currentAmountLoaded = evt.loaded || this.
        currentAmountLoaded;
    this._totalToLoad = evt.total || this.
        currentAmountLoaded;
    if (this._progressAvailable) {
        this._loadingText = "Loading Space-Truckers: 
            The Video Game... " + ((this._current
                AmountLoaded / this._totalToLoad) * 100).
                    toFixed(2);
    }        
}

evt事件数据对象用于设置progressAvailable属性值。如果进度事件没有可计算的长度,currentAmountLoaded在未完成时设置为0(false),在完成时设置为1(true)。否则,它设置为已加载的字节数。如果我们能计算出加载的百分比,我们就这样做,并相应地设置loadingUIText后端字段。加载界面的最后一部分是显示loadingText和进度字符串(如果可用)。

显示加载文本和进度

为了在我们的场景中显示文本,我们将使用 Babylon.js 的2D GUI系统。关于这一点,在本章的后面部分会有更多介绍,所以现在,请将以下内容复制并粘贴到SpaceTruckerLoadingScene构造函数的末尾:


this._textContainer = AdvancedDynamicTexture.CreateFullscre
    enUI("loadingUI", true, this._startScene.scene);
const textBlock = new TextBlock("textBlock", this._
    loadingText);
textBlock.fontSize = "62pt";
textBlock.color = "antiquewhite";
textBlock.verticalAlignment = Container.VERTICAL_ALIGNMENT_
    BOTTOM;
textBlock.paddingTop = "15%";
this._textContainer.addControl(textBlock);

我们在这里所做的只是创建一个新的AdvancedDynamicTexture,其大小与渲染画布相匹配,然后添加一个TextBlock,我们在将其添加到纹理的控制集合之前对其进行了一些大小、颜色和位置的调整。

注意

如果onProgressHandler可用,它将更新loadingUIText的值。

我们已经完成了加载屏幕功能,现在是在index.js组件中全局连接它的时候了。这只是一行代码,它是在创建eng实例之后立即添加的:


const eng = new Engine(canvas, true, null, true);
logger.logInfo("Created BJS engine");
eng.loadingScreen = new SpaceTruckerLoadingScreen(eng);

就这些了!现在,每当代码请求引擎显示加载 UI 时,我们的小行星动画就会显示出来。虽然这看起来可能是一个微不足道的功能,但完成这个应用程序的部分使我们准备好稍微改变一下节奏,并检查我们如何管理 Space-Truckers: The Application 的整体行为。

Space-Truckers: 状态机

对于熟悉游戏开发的人来说,可能会熟悉游戏结构围绕一系列循环的概念。更新循环运行模拟和物理,根据最新更新移动对象并应用效果。渲染循环是场景实际绘制到屏幕上的时刻。我们之前已经看到了这样的例子,比如当我们为**scene.onBeforeRenderObservable**添加事件监听器时,但这比我们现在关注的级别要低。我们的应用程序将作为多个不同 BJS 场景的宿主,因此它需要一种定期更新应用程序状态以及告诉活动场景进行渲染的方法。最后,它必须能够管理在不同场景之间的转换。

我们正在构建的应用程序在如何响应输入以及随时间演变其内部状态方面有一些隐含的要求。例如,当玩家选择菜单项或退出当前游戏时,系统必须通过改变(或“变异”)其数据来填充和渲染子菜单,或者返回主菜单。隐含的要求会导致软件设计不良,因此我们将从使隐含的显式化开始。

日志间歇

我们的应用程序即将变得更加复杂,因此现在是开始添加基本仪表和调试消息的好时机——我们总是可以在以后增强和改进日志程序,但没有它们的话,随着代码量的增加,开始的地方会更加困难。带有其导出类 ConsoleProxy 的源文件 logger.js 是一个围绕控制台对象的极其基本的包装器,它提供了将不同级别的日志消息(INFO、WARN、ERROR 和 FATAL)记录到控制台(如果存在)的功能。不同的日志方法具有相同的主体(如果你觉得这让你感到困扰,修复它并提交一个 PR!开源软件的美丽之处正在发挥作用),为了节省空间,以下代码中只展示了其中一个函数:


class ConsoleProxy {
    constructor(console) {
        this._console = console;
        this._consoleIsPresent = this._console == true;
        this._messageBuffer = [];
    }
    logInfo(message) {
        const logObj = { type: "INFO", message: message};
        if (this._consoleIsPresent) { 
            this._console.log(logObj);
            return;
        }
        this._messageBuffer.push(message);
    }
// …
}
const theProxy = new ConsoleProxy(console);
export default theProxy;

之前的大部分代码相当标准——你几乎在任何自制的应用程序框架中都能看到这种类型。constructor 接受一个 console 参数,它使用该参数来设置存在标志。这是因为不能保证 console 对象总是可用,我们不希望任何日志调用失败并导致应用程序的其他部分出现问题。当控制台不可用时,_messageBuffer 数组用作后备。在这种情况下,可以通过附加调试器并读取日志数组的内 容来访问应用程序日志。如果需要,可以轻松扩展以适应当前场景。theProxy在导出为单个对象之前被实例化。日志记录器的消费者不会实例化一个新的日志实例——他们只需调用export default行,并将theProxy改为logger`。我们希望这部分内容方便使用,以便我们可以轻松地测试和验证代码的正确行为,或者你可以参考片段 #EK321G 作为起始参考模板。

生成器和 function* 迭代器

从软件设计的角度来看,我们将把我们的状态机视为一种 迭代器,或者一种循环结构,其中每次迭代 yield 下一个(或当前)状态,同时也允许调用者指定状态条件。提供这种功能的 JavaScript 语言结构被称为 生成器函数,或 函数*。

MDN Web Docs 在 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* 中这样描述生成器和它们的行为:

“生成器是可以退出并在以后重新进入的函数。它们的上下文(变量绑定)将在重新进入时被保存……”

“当调用迭代器的 next() 方法时,生成器函数的主体将执行,直到遇到第一个 yield 表达式,该表达式指定了要从迭代器返回的值”

“使用带有参数的next()方法将恢复生成器函数的执行,用next()的参数替换执行暂停处的yield表达式”

“在生成器中,当执行return语句时,将使生成器完成”

编写生成器函数

查看实际代码比阅读其描述更有帮助,因此让我们启动一个新的 Playground Snippet 并编写一些代码。使用基础 PG 片段(为我们的生成器函数存根使用createScene函数):


    function* appStateMachine() {
        let currentState = "INDETERMINATE";
        yield currentState;
        yield currentState + "-POST";
        yield "DONE";
    }

记住,当此函数体执行时,每当遇到yield语句时,控制权都会转移。value由迭代器返回,其形式为一个看起来如下结构的对象:{ value: <yielded value>, done: false|true }。在先前的代码中,我们在currentState变量之前定义并设置了一个局部变量currentState。在执行恢复后,代码再次产生,这次在隐式返回之前带有短语“DONE”

使用生成器

为了最好地说明我们刚刚定义的appStateMachine生成器的某些非直观行为。请在自己的 Playground 中跟随,或者跳过并加载本节结果的下一个片段修订版(我们从 0 开始)——#EK321G#1

使用我们的appStateMachine生成器的第一种——可以说是最简单的方法——是使用yield语句:


    let index = 0;
    const asm = appStateMachine();
    for (const a of asm) {
        logger.logInfo("Index " + index++, a);
    }

在先前的片段中,在递增之前将索引变量的值记录到控制台,这是一种方便显示代码行为的方式。打开您的浏览器开发者工具,在点击运行后查看控制台输出。输出应类似于以下内容:

{type: "INFO", message: "Index 0"} "INDETERMINATE"
{type: "INFO", message: "Index 1"} "INDETERMINATE-POST"
{type: "INFO", message: "Index 2"} "DONE"

您可以从Index值的02的进展中看到,yield语句是如何在生成器函数和for...of循环之间切换代码执行的。这意味着of迭代生成器最适合那些循环逻辑不需要做很多繁重工作或如果您编写的代码需要正确顺序地协调许多不同的异步操作,并且您不需要对迭代有精细控制的情况。

除了迭代生成的函数之外,另一种用法是手动调用next()函数来转移控制。每次调用都相当于之前讨论的循环结构的迭代,但请记住,区别在于,你不会直接得到yield语句中包含的任何值,而是返回一个具有valuedone属性的迭代器对象:


    const asm2 = appStateMachine();
    let s0 = asm2.next();
    let s1 = asm2.next();
    let s2 = asm2.next();
    let s3 = asm2.next();
    logger.logInfo("s0", s0);
    logger.logInfo("s1", s1);
    logger.logInfo("s2", s2);
    logger.logInfo("s3", s3);

运行此代码将产生与先前代码相同的输出,但多出一个值。与只有三个单独的索引值不同,这种方法让你拥有四个:

{type: "INFO", message: "s3"} {value: undefined, done: true}

这个“s3”对象没有值,并且它的done标志被设置为true,表示序列已完成。任何对asm2.next()的进一步调用都将返回相同的undefined值和true标志。这种方法的优点是,生成器的消费者可以有很多控制权来决定何时以及如何调用next(),这是我们创建第一个状态机时即将使用的一个关键特性。

状态机的定义

计算机科学中的一个核心概念是有限状态机FSM)——或者简称为状态机——根据我们的目的,它通过以下重要属性被定义和描述:

  1. 系统在任何给定时间只能处于一个状态。

  2. 状态机有有限数量的可能状态。从实用角度来看,至少有一个初始状态和一个最终状态用于系统。

  3. 状态之间的转换是在响应命令、外部输入或环境中的其他变化(例如,时间流逝)时触发的。

  4. 在渲染帧之前,状态机应该用有关状态的最新信息进行更新。

让我们更详细地看看这些点。

一次一个状态

这一点相当直观。一个给定的状态机可能只能处于一个状态,无论可能有多少个有效的状态——不存在混合、聚合或混合类型的状态。在代码术语中,这意味着我们的状态机将只有一个字段或属性来表示其当前状态。这并不是说特定的状态机不能有具有自己状态的属性(例如,一个动画可能处于 RUNNING 状态),只是整个状态机在任何给定时间只能被分类为处于一个状态。在撰写本文时,量子计算尚未达到主流可用性,巧妙地避免了任何关于潜在本征态的讨论——潜在状态的概率组合——并将主题内容牢固地根植于经典计算理论。呼,真是个解脱!

有限数量的状态,起始和结束状态

机器需要有一个初始状态来开始,也应该有一个结束状态。技术上,结束状态和初始状态可以是相同的,但这并不构成非常有趣或相关的软件。在起始和结束之间可以有任意数量的状态,尽管为了保持实用性,我们只会关注定义其中的一小部分。

当发生某些事情时发生转换

听起来很傻,但这是真的。在给定的更新周期过程中,应用程序或游戏逻辑可能会接收到触发状态转换的输入事件。我们 FSM 定义的一部分是调用任何给定状态转换的逻辑。这意味着我们的代码将包含具有诸如goToMainMenu等名称的转换方法。

注意

如果有帮助,试着将状态视为描述系统内部数据的单一、离散组合的简写方式。状态转换是控制一个内部数据组合向另一个不同数据排列的突变逻辑。

更新状态机

所有这些的总和是我们可以频繁地推进或进化机器状态的机制。因为我们将要管理多个场景,我们不能使用像scene.onPreRenderObservable这样的东西,因为我们已经为像动画行星轨道这样的东西使用过了。相反,我们将利用engine.runRenderLoop回调作为确保无论渲染哪个场景,我们的更新逻辑都会被调用的方式。这也很好地满足了在渲染帧之前更新状态的要求。

重要提示

如果您需要确保动画和物理同步,或者如果您需要帧率无关的渲染,您需要确保执行以下两项操作:

a) 在创建引擎实例时设置选项参数的deterministicLockstep标志

b) 使用onBeforeStep以及onAfterStep可观察对象,而不是使用onPre/onAfterRenderObservable集合来执行状态更新

在我们了解了如何构建应用程序的下一部分之后,是时候查看我们设计的具体细节,并开始原型化 Playground 片段。

Space-Truckers:应用程序状态图

在我们深入编写 FSM 的代码之前,我们应该花点时间弄清楚我们将要构建什么。我们需要在开始时做出的一项重要区分是应用程序的游戏和非游戏部分。游戏将有自己的状态机来管理游戏的各个阶段,每个阶段又可以有自己的迷你状态机。完全是状态机!以下图显示了每个状态以及它们之间的转换。图中的圆圈代表由外部输入触发的事件或转换,例如用户点击按钮:

![图 4.1 – 来自早期 Space-Truckers 设计流程的状态图,展示了应用程序和游戏状态 – 状态之间的转换(线条和箭头)是按顺序发生的(例如初始化)或作为输入事件的结果(例如用户取消)

![img/Figure_4.01_B17266.jpg]

图 4.1 – 来自早期 Space-Truckers 设计流程的状态图,展示了应用程序和游戏状态 – 状态之间的转换(线条和箭头)是按顺序发生的(例如初始化)或作为输入事件的结果(例如用户取消)

现在,我们将忽略图表的下半部分。作为一个早期迭代版本,其中一些内容(即,剪辑场景)无论如何都是充满希望的。查看图表的上半部分,如果我们考虑加载界面处于初始化状态,那么我们可以看到状态和应用屏幕之间的一对一对应关系。这也应该开始变得清楚,每个屏幕也对应一个 BJS 场景。沿着这些思路推理,我们可以将不同的CutSceneSplash Screen项目概括为同一事物的两个独立实例(尽管内容不同,但这在这里并不相关)。以下是图表中我们已识别出的屏幕和场景:

图 4.2 – 应用级状态和转换规则的表格

图 4.2 – 应用级状态和转换规则的表格

这看起来可能需要吸收很多内容,但实际上并没有看起来那么复杂。现在是时候打开VSCode并开始添加一些新的代码了。你可以在这里跟随,或者如果你更愿意复制、粘贴并修改现有代码,请访问片段#EK321G#6。记住,随着你将片段整合到代码中,你需要进行类似我们在上一章中进行的类似类型的调整。

几乎无限循环状态

我们将要添加到我们的项目中的第一个东西是appstates.js,到项目的/src目录。由于这是一个非常简单且不会改变的对象,我们可以使用Object.freeze来确保在运行时值不会被更改:


export default Object.freeze({
    CREATED: 0,
    INITIALIZING: 2,
    CUTSCENE: 3,
    MENU: 4,
    RUNNING: 5,
    EXITING: 6
});

在添加了spaceTruckerApplication.js之后,包含一个名为(惊喜!)SpaceTruckerApplicationclass定义:


class SpaceTruckerApplication {
}

这个类是这个应用程序的核心类(正如其名称所暗示的)。随着时间的推移,它将变得更大,所以在你开始通过定义我们的appStateMachine函数来着手之前,请珍惜它所有可爱的简洁性。在类内部添加一个函数定义。

如我们之前讨论的,状态机需要有一个且只有一个当前状态。在状态计算中,能够将当前状态与之前的状态的任何值进行比较非常有用,因此在Generator函数的主体中添加一些变量声明来包含这些值,以及一个辅助函数来更改它们:


function* appStateMachine() {
        let previousState = null;
        let currentState = null;
        function setState(newState) {
            previousState = currentState;
            currentState = newState;
            logger.logInfo("App state changed. Previous 
                state:" + previousState + « 
                    New state: " + newState);
            return newState;
        }
}
// … create scene, camera return scene

现在,我们可以将注意力转向状态机的输出——它将返回给调用者的内容。我们之前的小样本一旦达到其序列的末尾就会简单地停止(返回done: true),但我们希望我们的有限状态机(FSM)在应用程序运行期间一直运行,并且我们事先不知道这意味着要调用生成器的next()方法多少次。我们解决这个问题的方法是将这个调用放在一个无限循环中。

每次循环首先从调用者那里接收输入,以指示所需的nextState——调用者通过将值作为参数传递给setState方法来执行实际的状态更改。一旦发生这种情况,代码将检查是否满足达到最终状态(AppStates.EXITING)的条件,如果是,则返回currentState——否则,它将在循环的顶部yield回调用者:


while (true) {
    let nextState = yield;
      if (nextState !== null && nextState !== undefined) {
            setState(nextState);
            if (nextState === AppStates.EXITING) {
                return currentState;
            }
        }
}

我们的状态机实现(目前)已完成,现在是时候连接支持应用程序逻辑了。

添加构造函数和支持逻辑

我们需要通过从我们的生成器创建一个函数以及其他创建任务来初始化状态机,因此,在我们的新类中添加一个构造函数。由于我们使用这个类创建和管理场景,我们需要带上_engine。在此期间,我们不妨调用生成器并添加一个用于跟踪要渲染的场景的字段。最后,构造函数中的最后一个动作是将应用程序的状态从之前的undefined值转换为CREATED。我们将通过调用即将创建的moveNextAppState函数来完成这项工作(请参阅以下代码块):


constructor(engine) {
    this._engine = engine;
    this._currentScene = null;
    this._stateMachine = this.appStateMachine();
    this.moveNextAppState(AppStates.CREATED);
}

写出像this._stateMachine.next().value这样的语句可能会很繁琐,更糟糕的是,它向不需要知道这类信息的代码揭示了内部实现细节,这使得未来进行更改变得更加困难。让我们通过添加一些访问器属性来获取currentStateactiveScene,来保护我们其余的代码免受这种处理的困扰。此外,正如之前提到的,我们将添加moveNextAppState辅助方法来帮助我们隐藏向状态机传递和接收值的过程:


    get currentState() {
        return this._stateMachine.next();
    }
    get activeScene() {
        return this._currentScene;
    }
    moveNextAppState(state) {
        return this._stateMachine.next(state).value;
    }

在我们继续之前,有一点非常重要需要注意,即应用程序必须尊重其边界,在构建期间不要尝试执行重型加载任务。

这种类型的任务是为AppStates.INITIALIZING保留的,而这样做的原因对于用户体验至关重要。我们不希望在用户明确决定启动游戏之前执行任何可能将大量数据传输到客户端的操作。这尊重了那些可能对游戏感兴趣但数据或带宽连接有限的人,并强制在基于 HTML 的着陆页和基于 WebGPU 或 WebGL 的游戏之间进行清晰的分离。

重要提示

我们之前查看的状态图是在用户点击我们着陆页上的启动按钮时开始的。

点击着陆页的run按钮对SpaceTruckerApplication类的影响。这是我们将引擎的runRenderLoop回调与我们的applicationStateMachine连接起来的地方:


    run() {
        this._engine.runRenderLoop(() => {
            // update loop
            let state = this.currentState;
            switch (state) {
                case AppStates.CREATED:
                case AppStates.INITIALIZING:
                    break;
                case AppStates.CUTSCENE:
                    break;
                case AppStates.MENU:
                    break;
                case AppStates.RUNNING:
                    break;
                case AppStates.EXITING:
                    break;
                default:
                    break;
            }
            this._currentScene?.render();
        });
    }

runRenderLoop回调中,我们通过使用 getter 方法调用_applicationStateMachine.next()函数(无任何参数)来获取currentState。目前没有什么可看的,但占位符的switch语句显示了每个状态的处理位置。前两个状态CREATEDINITIALIZING被分组,因为它们没有被渲染——或者至少在INITIALIZING的情况下,加载 UI 是该状态的渲染输出。一旦完成场景选择和管理,将调用_currentScenerender()方法(如果存在)。

run的初始调用连接起来,我们将在index.js文件中添加两行。还需要清理一些现在已过时的代码——我们不希望index.js调用createStartScene,也不希望它与引擎的渲染循环交互。在创建和设置SpaceTruckerLoadingScreen之后,声明并实例化一个新的SpaceTruckerApplication实例。由于它作为一个类型名称已经相当好,只需将其称为theApp。接下来,在启动按钮的点击处理程序中添加一行来调用theApp.run()。在开发过程中,在代码的关键区域添加日志语句可能会有用,以帮助理解应用程序的运行行为,因此要充分使用它们!这是我们应用程序的状态管理功能的基本框架,已经全部连接好,准备填充更多有趣的状态和行为。为此,我们开始具体化这些状态和行为,为构建主菜单做准备。

编写初始化逻辑

返回到状态图,一旦应用程序完成初始化,它应该过渡到显示开场画面(场景)然后再过渡到主菜单。这是一个很好的线性进展,因此借助await ES6 特性实现起来很简单。

由于INITIALIZING状态是构建后的第一个状态,它应该是run()方法中首先发生的事情。随着这个变化,我们还需要将run()方法标记为async,以便我们能够使用这个语言特性,因此将函数的前几行更改为以下内容:


    async run() {
        await this.initialize();
    // …

现在,添加initialize函数的功能。我们希望这个方法完成几个任务,其中一些我们将暂时进行模拟。另一个方法占位符goToMainMenu帮助我们完成状态图的第一个部分,我们将构建以下内容:


    async initialize() {
        this._engine.enterFullscreen(true);
        this._engine.displayLoadingUI();
        this.moveNextAppState(AppStates.INITIALIZING)
        // for simulating loading times
        const p = new Promise((res, rej) => {
            setTimeout(() => res(), 5000);
        });
        await p;
        this._engine.hideLoadingUI();
        this.goToMainMenu();       
    }

首先,我们从引擎请求全屏会话。这相当于用户选择他们的网络浏览器的全屏选项,我们希望在开始任何严肃的渲染之前做这件事——在没有渲染内容时应用画布缩放或大小更改要快得多。接下来,我们想要显示引擎的加载 UI——如果你还记得,我们在代码库中用我们自己的自定义加载 UI 替换了它。

注意

当在 Playground 中运行时,将显示默认的 Babylon.js 加载 UI,而不是我们的自定义 UI。

之后,我们就正式进入了INITITIALIZING状态,因此我们通过调用带有新状态的moveNextAppState来过渡到该状态。最后,我们通过创建一个在超时后解决的新的Promise来模拟 5 秒的加载时间。我们在隐藏加载 UI 和启动下一个状态转换到MENU状态之前等待这个操作完成。

转换到主菜单

goToMainMenu函数定义非常简单,因为它有一个非常具体的任务。它需要在转换到MENU状态之前创建一个(即将创建的)MainMenuScene类的实例。将以下函数定义添加到类中:


    goToMainMenu() {
        this._engine.displayLoadingUI();        
        this._mainMenu = new MainMenuScene(this._engine);
        this._engine.hideLoadingUI();
        this.moveNextAppState(AppStates.MENU);        
    }

在我们可以完成连接我们的状态机之前,还需要进行一个更改。在我们的主Update循环中,在AppStates.MENU情况语句下,我们需要将_currentScene值设置为我们的主菜单场景:


    case AppStates.MENU:
       this._currentScene = this._mainMenu.scene;
       break;

当然,这目前还不存在,现在是解决这个缺陷的好时机!创建另一个新的 JS 文件,mainMenuScene.js,并在代码片段中添加一个名为MainMenuScene的占位类。实现其构造函数以接受一个引擎实例;它还应该创建一个新的scene。为了与现有的背景融合,我们将scene.clearColor设置为不透明的黑色,分别为0001。相机的距离参数设置为-30,看起来有些随意——然而,这个值在我们渲染动画背景时将变得非常重要。这是你的类定义在添加基本元素后的样子(不要忘记添加import语句为SceneVector3ArcRotateCamera,并在文件顶部添加from “@babylonjs/core”,在文件底部添加export default MainMenuScene!):


class MainMenuScene {
    get scene() {
        return this._scene;
    }
    constructor(engine) {
        this._engine = engine;
        let scene = this._scene = new Scene(engine);
        const camera = new ArcRotateCamera("menuCam",
            0, 0, -30, Vector3.Zero(), scene, true);
    }
}
export default MainMenuScene;

检查是否有语法错误或其他问题,并确保保存和提交你的工作。这里的事情即将变得更加有趣!

我们基本状态机的最终列表在代码片段#EK321G#6中。不要被看似缺乏成就所欺骗——使用视觉指标来衡量进度并不总是明智的。我们已经通过这个基础工作打下了基础,这将有助于我们未来的努力,随着我们寻求在多个场景和屏幕之间进行协调,这将会更有意义。我们将要构建的第一个屏幕是主菜单,在我们的图中,它不是序列中的下一个状态——启动场景才是——但我们在构建一些作为构建过场场景所需的部分显示和转换逻辑之后将返回那里。

Space-Truckers: 主菜单

几乎所有现有的视频游戏都有的一个主要共同特征是它们都有一个主菜单。太空卡车手也不例外,但我们首先必须坐下来,弄清楚我们想要我们的菜单看起来如何,然后我们才能制作它。我们从菜单布局和元素的基本概念草图开始,然后将其用作构建菜单片段的指南。从背景到前景,我们将逐步构建 GUI 菜单显示,添加容器、标题块,然后是准备拖放到代码库中的按钮!

基本设计

首先,让我们考虑应用程序的导航结构。根据我们的状态图(图 4.1),我们可以看到有几个不同的分支可以从菜单应用程序状态转换。除了最初进入主菜单的转换外,每条路径都代表一个不同的菜单项或选择选项:

  • 菜单转换到运行将由用户通过点击播放按钮触发。

  • 点击退出按钮可以触发退出应用程序。

  • 通过点击相应的按钮可以访问附加菜单。最初,我们只会创建一个高分子菜单。

在外观上,我们希望菜单功能上吸引人,并在一段时间内在前景和背景中展示一些动态行为。另一个考虑因素是,由于玩家可能使用游戏手柄或控制器而不是键盘和鼠标,我们希望有一个选择指示器,显示玩家通过点击或按下他们控制器上的相应按钮将调用哪个菜单项。以下草图显示了在没有背景的情况下这可能看起来如何:

![Figure 4.3 – A Main Menu design sketch

![img/Figure_4.03_B17266.jpg]

图 4.3 – 主菜单设计草图

为了让菜单在背景中脱颖而出,我们将用渐变或其他半透明图像填充它,同时给容器添加边框。

背景不需要有很多内容 – 时间和带宽限制可能会将这块特定内容放在低优先级轨道上。这没关系,因为我们可以快速轻松地放置一些看起来不错且能实现我们想要的功能的东西 – 你还记得我们那位老朋友星域程序纹理PT)吗?我们将使用它为菜单添加一个酷炫的太空主题背景,然后我们将对其进行动画处理,以产生旅行的错觉。

在你的网络浏览器中切换回游乐场,点击新建图标来为我们的主菜单创建一个新的片段。

设置主菜单片段

了解我们计划如何将我们的片段中的代码在某个时候传输到我们的代码库中,这是一个很好的机会,投入时间和精力使这个过程尽可能快、可靠和准确。

我们可以通过在片段顶部定义一些别名来开始,这些别名对应于各种BABYLON组件和命名空间,就像我们在第三章中做的那样,建立开发工作流程


const {
    Color4,
    Vector3,    
    ArcRotateCamera,
    Scene    
} = BABYLON;

随着我们涉及来自本地文件的更多MainMenuScene类定义,此列表中将有更多项目需要添加。

注意

我们刚刚定义的别名列表中的项目在集成到代码库时需要转换为导入语句。

当我们准备集成和提交更改时,我们将通过本质上执行相同操作的反向操作来更新本地文件。在片段的createScene函数中,实例化一个新的MainMenuScene实例并返回其场景属性,这样就会将我们的萌芽MainMenuScene类连接到片段的渲染循环中:


var createScene = function () {
    const mainMenu = new MainMenuScene(engine);
    return mainMenu.scene;
};

简短明了,我们再也不用考虑这个片段的这一部分了。

构建背景

我们将从场景的一般环境和背景设置开始,所以滚动到类定义并添加一个名为_setupBackgroundEnvironment的新实例方法。这就是我们将实例化和配置作为屏幕背景的星系 PT 的地方。这也是我们将设置纹理以在一段时间内逐渐动画化,从而产生穿越星系的错觉的地方。

在构造函数的末尾调用this._setupBackgroundEnvironment(),这样我们就可以立即使用运行按钮来查看结果。然而,在编写函数的主体之前,将这些类型添加到顶部的别名定义列表中:

  • HemisphericLight

  • StarfieldProceduralTexture(单独一行,你以后会感谢自己的)

  • StandardMaterial

  • CylinderBuilder

  • Texture

场景已经有一个位于原点-30 单位处并指向原点的相机,但它还需要一盏灯以及照亮该灯的东西。与我们在加载界面中使用立方体网格作为天空盒不同,我们将创建一个圆锥形形状,通过为每个端盖制作不同半径的管子来实现。将星系 PT 应用到圆柱体的内部需要我们将backFaceCulling设置为false,因为我们想看到内部面。为了动画化星系,我们可以在渲染每一帧之前简单地增加StarfieldProceduralTexturetime属性:


_setupBackgroundEnvironment() {
    const light = new HemisphericLight("light", new Vector3
        (0, 0.5, 0), this._scene);
    const starfieldPT = new StarfieldProceduralTexture
        ("starfieldPT", 1024, this._scene);
    const starfieldMat = new StandardMaterial("starfield", 
        this._scene);
    const space = CylinderBuilder.CreateCylinder("space", 
          { height: 64, diameterTop: 0, diameterBottom: 64,
              tessellation: 512 }, 
          this._scene);
    starfieldMat.diffuseTexture = starfieldPT;
    starfieldMat.diffuseTexture.coordinatesMode = Texture.
        SKYBOX_MODE;
    starfieldMat.backFaceCulling = false;
    starfieldPT.beta = 0.1;
    space.material = starfieldMat;
    return this._scene.onBeforeRenderObservable.add(() => {
        starfieldPT.time += this._scene.getEngine().
            getDeltaTime() / 1000;
    });
}

HemisphericLight是 Babylon.js 中的一种光源,它模拟了环境光照类型。通过玩弄漫反射镜面反射以及这种类型的光的独特之处地面颜色的组合,你可以实现许多有趣的效果,但鉴于我们的需求相当简单,我们目前不需要这样做。

重要提示

将场景的 delta 时间除以 1,000 设置了星系闪烁和移动的速度。尝试删除除法语句,看看会发生什么!

完成函数,我们遵循与我们在创建加载屏幕的行星轨道动画时相同的模式,通过注册onBeforeRenderObservable并返回观察者来整洁地处理。如果一切顺利,点击运行按钮应该会显示我们星系的美丽画面,随着它缓慢地移动而闪烁和闪耀。

点击#16XY6Z查看此开发阶段此代码片段的完整代码。

创建高级动态纹理和 GUI

当涉及到 Babylon.js 2D GUI 系统中的广泛功能时,可能会有很多内容需要吸收。更详细的 GUI API 文档可以在doc.babylonjs.com/divingDeeper/gui/gui找到,但我们现在将要使用它的内容应该要么刷新你的记忆,要么提供足够的基础知识以开始学习。向别名列表添加新类型,但不是将它们放入BABYLON对象中,而是添加一个新的BABYLON.GUI条目,它与BABYLON条目类似,包含来自BABYLON.GUI命名空间的下述类型:

  • AdvancedDynamicTexture

  • Rectangle

  • Image

  • StackPanel

  • TextBlock

  • Control

MainMenuClass中添加一个名为_setupUi的新方法,并在构造函数中添加一行代码,在函数底部调用它。

目前我们不会尝试对菜单 UI 做任何复杂的设计,所以_setupUi函数首先需要做的是在(默认的)全屏模式下创建BABYLON.GUI.AdvancedDynamicTexture类的一个实例。这将产生一个与渲染画布大小相同的 2D 纹理,控件被绘制在其上,然后这个纹理被渲染在场景之上。我们将进行的一个小调整是告诉纹理以理想大小渲染——这将有助于避免由于下采样或上采样效果引起的渲染文本的模糊。为了允许其他类实例方法访问纹理,将其分配给_guiMenu属性:


const gui = AdvancedDynamicTexture.CreateFullscreenUI("UI");
gui.renderAtIdealSize = true;
this._guiMenu = gui;

接下来,我们需要添加一个Rectangle控件来包含实际的菜单项。我们不想让它完全不透明,但它应该有一个对比鲜明的背景颜色或渐变。

添加菜单容器和背景

对于网页开发者和设计师来说,有很多希望令人感到舒适熟悉的概念正在发挥作用。GUI 控件树是一个类似于AdvancedDynamicTexture的层次结构,无论是直接还是间接。通常,展示它比描述它更容易,所以添加以下代码来定义我们的菜单容器和基本外观:


        const menuContainer = new Rectangle("menuContainer");
        menuContainer.width = 0.8;
        menuContainer.thickness = 5;
        menuContainer.cornerRadius = 13;
        this._guiMenu.addControl(menuContainer);
        this._menuContainer = menuContainer;

宽度设置为画布大小的百分比(0.8),这样菜单就不会覆盖整个背景,而边框宽度(厚度)以像素为单位,而角落半径以度为单位——你明白了吗?

小贴士

Intellisense 可以成为您的最佳朋友,在提供 GUI 控件上众多属性的快速描述方面,尤其是当涉及到确定使用的单位(例如像素或百分比)时。

接下来,我们想要添加一个图像控件来持有菜单的背景。关于图像,创建一个漂亮的背景图像纹理很容易,但如果它在 PlayGround 中看不到,那又有什么用呢?所以,现在是时候施展魔法了…

图像旁白:引入外部内容

Babylon.js PlayGround 在其网络服务器的配置中有一个功能,允许从许多知名和建立的存储库主机(如 GitHub)共享跨源资源(CORS)。通过构建到我们源存储库的适当 URL,我们可以在 PlayGround 片段中加载纹理、声音和模型——就像在 Babylon.js 资产库 中一样!为了演示这是如何工作的,请将以下行添加到片段的顶部(第一行):


const menuBackground = https://raw.githubusercontent.com/jelster/space-truckers/ch4/assets/menuBackground.png
     + "?" + Number(new Date());

将 URL 分解,以下是您如何将此策略应用于任何公开托管的 GitHub 存储库:

  1. 从基本 URL raw.githubusercontent.com 开始,按顺序添加存储库所有者(或拥有组织)的名称和存储库本身的名称——例如,raw.githubusercontent.com/jelster/space-truckers

  2. 接下来,添加一个路径段,用于指定从哪个分支或标签中检索资产。对于本书,资产将列在其章节对应的分支中,但对于许多其他存储库,这将是 mainmaster 或可能是 develop

  3. 最后,将路径的其余部分添加到资产中,包括文件扩展名。因为这些文件有相当健壮的缓存头,所以当进行内容生产时,通常将一个使用缓存的字符串(如当前日期和时间)附加到 URL 的末尾是个好主意,这样您可以确保始终看到文件的最新版本。

使用 menuBackground URL,创建一个 Image 并将其添加到我们之前添加的 menuContainer


        const menuBg = new Image("menuBg", menuBackground);
        menuContainer.addControl(menuBg);

通过点击 运行 测试您的进度,修复任何问题,然后当然要确保 保存 片段。要检查自己或从本章的最新片段开始,请使用 #16XY6Z#1。它应该看起来是这样的:

![图 4.4 – #16XY6Z#1 的主菜单具有星系 PT 背景,以及一个半透明的渐变填充矩形,该矩形将包含菜单项]

![img/Figure_4.04_B17266.jpg]

图 4.4 – #16XY6Z#1 的主菜单具有星系 PT 背景,以及一个半透明的渐变填充矩形,该矩形将包含菜单项

布局标题和菜单项

回到图 4.2,我们可以看到菜单屏幕可以被分成一个两行的网格——一行用于标题,一行用于菜单项。为了确保按钮和选择图标都按照我们想要的方式对齐,我们需要网格有三个列,每个列宽是网格宽度的三分之一(网格本身的宽度为 0.8 或 80%)。使用 addColumnDefinitionaddRowDefinition 方法来完成此操作,使得设置非常简单,可以添加到我们的 _setupUi 方法中:


        const menuGrid = new GUI.Grid("menuGrid");
        menuGrid.addColumnDefinition(0.33);
        menuGrid.addColumnDefinition(0.33);
        menuGrid.addColumnDefinition(0.33);
        menuGrid.addRowDefinition(0.5);
        menuGrid.addRowDefinition(0.5);
        menuContainer.addControl(menuGrid);        
        this._menuGrid = menuGrid;

标题文本是定义游戏或应用程序外观和感觉的重要因素,通过其字体和显示方式,但我们将回到这个话题,在第七章**,处理路由数据。现在,我们将使用默认字体并确保文本按需自动调整大小。将 TextBlock 垂直对齐到网格的顶部将确保无论有多少按钮,标题都始终保持在正确的位置。添加一些样式以添加阴影和填充,结果代码类似于以下内容:


        const titleText = new TextBlock("title", "Space-
            Truckers");
        titleText.resizeToFit = true;
        titleText.textWrapping = GUI.TextWrapping.Ellipse;
        titleText.fontSize = "72pt";
        titleText.color = "white";
        titleText.width = 0.9;
        titleText.verticalAlignment = Control.
            VERTICAL_ALIGNMENT_TOP;
        titleText.paddingTop = titleText.paddingBottom = 
            "18px";
        titleText.shadowOffsetX = 3;
        titleText.shadowOffsetY = 6;
        titleText.shadowBlur = 2;
        menuContainer.addControl(titleText);

通过运行它并保存您的进度来检查您的作品。对于跟随的人,这可以在#16XY6Z#2中找到。下一个任务是编写一些功能来填充菜单的可选择按钮项。我们将做很多这些,所以我们可以少重复,就能节省更多的按键。

填充菜单项

类似于我们添加并实现 _setupUi 函数的方式,我们将通过添加 _addMenuItems 函数和构造函数调用表达式到我们的类中开始我们的最新任务。我们知道我们希望菜单中的所有按钮共享某些属性值的子集,但不是全部。特定菜单项实例的独特属性可以通过如下简单对象定义:


const pbOpts = {
    name: "btPlay",
    title: "Play",
    background: "red",
    color: "white",
    onInvoked: () => console.log("Play button clicked")
};

一个按钮需要有一个独特的名称,并且还需要一些文本来显示。前景色和背景色应该针对每个项目具体指定,当然,当按钮被选中时采取的操作当然也符合特定按钮的特性。在 _addMenuItems 定义中但在 pbOpts 表达式之前,添加这个局部辅助函数来创建并填充具有给定属性的按钮控件:


function createMenuItem(opts) {
    const btn = Button.CreateSimpleButton(opts.name || "", 
        opts.title);
    btn.color = opts.color || "white";
    btn.background = opts.background || "green";
    btn.height = "80px";
    btn.thickness = 4;
    btn.cornerRadius = 80;
    btn.shadowOffsetY = 12;
    btn.horizontalAlignment = Control.
        HORIZONTAL_ALIGNMENT_CENTER;
    btn.fontSize = "36pt";
    if (opts.onInvoked) {
        btn.onPointerClickObservable.add((ed, es) => 
            opts.onInvoked(ed, es));
    }
    return btn;
}

使用从我们的辅助方法返回的按钮,我们只需将其添加到菜单网格中:


const playButton = createMenuItem(pbOpts);
this._menuGrid.addControl(playButton, this._menuGrid.
    children.length, 1);

与其 addControl 函数的相同函数不同,GridaddControl 函数接受可选的 分配作为其第二个和第三个参数。这使得我们可以通过获取其子行数来在不了解其索引的情况下在最后一行插入一个项。我们希望按钮居中,所以列始终相同——一列。

通过根据这些选项添加退出按钮并不要忘记保存来完成按钮的设置!要比较检查点片段,请参阅#16XY6Z#3


const ebOpts = {
    name: "btExit",
    title: "Exit",
    background: "yellow",
    color: "black",
    onInvoked: () => console.log("Exit button clicked")
}

我们在本章中已经走了很长的路,但我们还没有完成。到目前为止,我们已经处理了很多不同的事情,我们计划构建的所有功能都已完成——现在,我们只需要将这个功能整合到我们的代码的其余部分。

添加菜单项选择和指示器

尽管有一大批玩家会想要并享受使用键盘和鼠标来玩《太空卡车手》,但它也应该是一个使用游戏手柄也能享受的体验。在下一章中,我们将更详细地探讨如何处理游戏手柄输入,为此,我们需要主菜单的项目可被选择,而无需调用它们的动作,也不需要在它们上方有鼠标指针悬停。一个选择指示器图标将起到这个作用,显示在当前所选菜单项旁边的图标,并显示在适当的按钮按下时将调用的命令或选项。

在我们到达所选项目的视觉方面之前,让我们以获取和设置函数对的形式给我们的类添加一些辅助属性,我们将称之为 selectedItemIndex。获取值很简单,使用 return this_selectedItemIndex。设置它稍微复杂一些。我们想要确保索引不超过菜单项的数量,并且当达到菜单项的末尾时,我们希望它从第一个项目重新开始。当所选项目索引改变时,我们还想执行其他一些操作,但设置方法不是执行任何比简单逻辑更复杂的地方,如下所示:


   get selectedItemIndex() {
       return this._selectedItemIndex || -1;
   }   
   set selectedItemIndex(idx) {
        const itemCount = this._menuGrid.rowCount;
        const newIdx = Scalar.Repeat(idx, itemCount);
        this._selectedItemIndex = newIdx;
        this._selectedItemChanged.notifyObservers(newIdx);
    }

我们之前在动画行星轨道时看到了 Scalar.Repeat 的用法。然后,我们使用它来确保弧度值保持平滑的圆形。同样,我们希望选择在达到末尾时平滑地循环。新项目(在前面代码中突出显示)是一个我们尚未声明的类成员,即 _selectedItemChanged 观察者。

指示选择并对变化做出反应

调用 scene.onBeforeRenderObservable。然而,这一次,我们不是在一个 BJS 对象上使用内置的可观察者,而是我们自己声明的一个。使用语义与我们所使用的其他可观察者完全相同——调用 add() 方法来注册一个函数,每当可观察者被触发时都会调用该函数。创建可观察者同样简单,通过创建一个新的 MainMenuScene 构造函数,添加代码来创建 _selectedItemChanged 可观察者,然后调用其 add 方法来注册我们的选择改变逻辑:


this._selectedItemChanged = new Observable();
this._selectedItemChanged.add((idx) => {
    const menuGrid = this._menuGrid;
    const selectedItem = menuGrid.getChildrenAt(idx, 1);
    if (selectedItem[0].isEnabled !== true) {
        this.selectedItemIndex = 1 + idx;
    }
    this._selectorIcon.isVisible = true;
    menuGrid.removeControl(this._selectorIcon);
    menuGrid.addControl(this._selectorIcon, idx);
});

当选择改变时,事件处理程序会传递新选择项的索引——它在网格中的行。有时,我们可能想要显示不可选的菜单项,因此我们检索所选项目,然后检查从所选行的第二列检索的项目是否isEnabled。如果不是这种情况,那么我们就增加selectedItemIndex——确保使用属性设置器而不是直接更改后端字段的值。我们事件处理程序的最后部分再次代表了我们还没有添加的内容——选择图标。这首先隐藏图标,然后从网格中移除它,并在新位置重新添加它。现在,再次在构造函数中添加对this._createSelectorIcon()的方法调用,然后向类中添加同名的函数声明。函数的主体应该如下所示:


_createSelectorIcon() {
    const selectorIcon = new BABYLON.GUI.Image
        ("selectorIcon", selectionIcon);
    selectorIcon.width = "160px";
    selectorIcon.height = "60px";
    selectorIcon.horizontalAlignment = Control.
        HORIZONTAL_ALIGNMENT_CENTER;
    selectorIcon.shadowOffsetX = 5;
    selectorIcon.shadowOffsetY = 3;
    selectorIcon.isVisible = false;
    this._menuGrid.addControl(selectorIcon, 1, 0);
    this._selectorIcon = selectorIcon;
}

这使用最终的未声明常量selectionIcon URL 字符串创建了一个新的GUI.Image。方法中的其余部分是我们不久前编写的样板代码。

注意

为了避免与 HTML DOM Image 类型混淆,Playground 中使用的是完全限定的名称。

通过在片段顶部添加selectionIcon URL 字符串来总结本节倒数第二个任务:


const selectionIcon = "https://raw.githubusercontent.com/jelster/space-truckers/ch4/assets/ui-selection-icon.PNG" + "?" + Number(new Date());

随意替换存储库中的图片,如果你想看到它在生产游戏中使用,请发送包含它的Pull Request给我们!最后,我们希望在场景完全加载并等待用户输入后自动选择菜单中的第一个项目。我们通过在构造函数末尾添加一行简单的代码来实现这一点:


scene.whenReadyAsync().then(() => this.selectedItemIndex = 0);

点击运行应该显示一个精心制作的主菜单——点击保存并祝贺自己。看看你在这本书的一个相对较短章节的一个相对较小的部分中完成了多少,然后思考你最终会走多远!为了比较你的代码进行故障排除或赶上进度,请参阅片段#16XY6Z#4。主菜单看起来不错,但尽管背景中有星系闪烁,它仍然需要一点动作来给它一些活力和能量。坦白说,退出按钮的蜂黄色也不是我们想要的外观,所以让我们在继续之前花点时间纠正这些问题。

视觉改进和选择图标动画

我们想要做的最简单的更改是将我们的ebOpts对象的颜色属性设置为字符串颜色black。对于下一个更改,我们将在选择图标上添加一个小动画,使其看起来像卡车漂浮在菜单项旁边。这是一个两步的过程,每个步骤的组件应该都来自最近的使用经验。

首先,我们需要使用名为 _selectorAnimationFrame 的类成员跟踪图标的当前动画帧。其次,我们需要注册一个 onBeforeRenderObservable,它将在渲染场景中的每一帧之前执行一个新函数 _selectorIconAnimation。在该函数中,我们增加当前帧(如果需要则循环)并使用该值根据我们的圆形待机 – 正弦函数来计算图标沿垂直轴的位置。这就是动画函数应该类似的样子:


_selectorIconAnimation() {
    const animTimeSeconds = Math.PI * 2;
    const dT = this._scene.getEngine().
        getDeltaTime() / 1000;
    this._selectorAnimationFrame = Scalar.Repeat(this._
    selectorAnimationFrame + dT * 5, animTimeSeconds * 10);
    this._selectorIcon.top = Math.sin(this.
        _selectorAnimationFrame).toFixed(0) + "px";
}

完成整个动画周期所需的总时间由第一个表达式给出,而自上一帧渲染以来经过的时间(以秒为单位)由第二个表达式给出。正如我们之前在 set selectedItemIndex 中所做的那样,当 _selectorAnimationFrame 达到帧计数时,我们在这里循环它,但同时我们通过任意因子缩放一些值,以产生新的 top 位置(以像素为单位),该位置在最后一行中设置。运行此操作应该会导致退出按钮的颜色更加令人愉悦,以及显示卡车选择图标的微妙浮动外观。

图 4.5 – 包含卡车图标浮动动画的主菜单片段

图 4.5 – 包含卡车图标浮动动画的主菜单片段

如果这开始感觉重复,那么这是好事,因为这意味着这本书中的材料开始深入人心!片段 #16XY6Z#5 有最新的代码;如果您还没有准备自己的,请导航到这个片段,并确保您已经打开了 VSCode,并准备好接受应用程序的新 Main Menu。

集成主菜单

尽管标题可能令人畏惧,但实际上我们不需要做太多工作就能将我们的片段中的所有工作整合到应用程序的代码结构中。事实上,在完成本章的所有努力和旅程之后,当我们完成这部分工作的时候,可能会感觉有点反高潮。

最直接和简单的方法是将片段中的整个 MainMenuScene 类复制粘贴到您的本地文件中,确保完全替换现有的类声明。您只需要稍微调整您的 import 语句;以下是其中两个最相关的行,其中发生了变化:


import { Scene, Vector3, Scalar, Observable, Sound, HemisphericLight } from "@babylonjs/core";
import { AdvancedDynamicTexture, Rectangle, Image, Button, Control, TextBlock, Grid, TextWrapping } from "@babylonjs/gui";

对于选择图标图像资产,请从片段的 URL 下载或自己制作。无论哪种方式,都要为它添加一个 import 语句:


import selectionIcon from "../assets/
    ui-selection-icon.PNG";

要么等待开发 webpack 输出完成,要么运行 webpack 进程来测试你的更改,并且别忘了提交和推送你的工作——没有理由因为遗漏几个按键而丢失工作。早些时候,当我们讨论我们的状态机时,我们了解到除了状态行为之外,定义从这些状态到其他状态的过渡也很重要。在过渡的话题上,现在有一个新的过渡!

进入和离开过渡

当我们查看我们的主菜单与 SpaceTruckerApplication 状态机的集成时,有两个函数我们还没有实现和连接。这两个函数是主菜单的两个过渡函数。换句话说,我们需要定义当我们过渡到 MENU 状态以及从该状态退出时会发生什么逻辑。命名这些新函数实际上相当简单——_onMenuEnter_onMenuLeave。虽然我们可能希望在以后实现更多复杂的行为,但现在,我们将说,当菜单开始或停止成为应用程序的当前状态时,我们希望它相应地淡入或淡出。

实现这一点的最简单方法是通过动画化menuContainer.alpha属性,在01(进入)或10(离开)之间。与选择图标动画一样,我们需要存储fadeInfadeOut的当前帧。与选择图标动画不同,动画应该持续的时间是有限的,因此我们还需要存储过渡的总duration值。在每一帧之间,我们应该将当前的alpha值设置为仅略低于前一个值的值,这样过渡看起来就更加平滑。最后,当动画结束时,我们希望(在离开过渡的情况下)将菜单的可见性设置为false,以及任何其他需要进行的清理工作。有趣的是,进入和离开过渡的逻辑几乎相同,只是SmoothStep函数中用于插值alpha值的范围需要交换。下面是_onMenuEnter函数:


_onMenuEnter(duration) {
    let fadeIn = 0;
    const fadeTime = duration || 1500;
    const timer = BABYLON.setAndStartTimer({
        timeout: fadeTime,
        contextObservable: this._scene.
            onBeforeRenderObservable,
        onTick: () => {
            const dT = this._scene.getEngine().
                getDeltaTime();
            fadeIn += dT;
            const currAmt = Scalar.SmoothStep(0, 1, fadeIn 
                / fadeTime);
            this._menuContainer.alpha = currAmt;
        },
        onEnded: () => {
            this.selectedItemIndex = 0;
        }
    });
    return timer;
}

我们不是使用 JavaScript 中标准的定时器创建方法setTimeout,而是使用BABYLON.setAndStartTimer实用函数。通过将contextObservable附加到scene.onBeforeRenderObservableonTick方法会在每一帧渲染之前一致地被调用。当定时器完成时,onEnded函数会被调用,正如其名称所暗示的。在我们的情况下,我们希望在菜单完全过渡进来之后再显示选择图标,所以我们将在那里设置selectedItemIndex。在构造函数中,我们可以用scene.whenReadyAsync调用的onMenuEnter函数替换掉使用的回调,如下所示:


       scene.whenReadyAsync().then(() => this._onMenuEnter());

保存文件并运行应用程序。你应该会看到菜单在几秒钟内淡入,然后出现选择项。了解更多关于此和其他相关功能的信息,请访问 https://doc.babylonjs.com/divingDeeper/events/observables#setandstarttimer,但也许可以稍等片刻再去做那件事——现在是时候完成本章了!

如前所述,onMenuLeave 函数几乎与其 onMenuEnter 对应函数相同(除了 onEnded 回调),只是在 SmoothStep 中交换了术语(如下)。添加 onMenuLeave 函数并使用更改后的表达式:


const currAmt = Scalar.SmoothStep(1, 0, fadeOut / fadeTime);

连接 onMenuLeave 很简单:在 _addMenuItems 方法的 ebOpts 对象定义中,将 onInvoked 函数更改为类似以下的内容:


onInvoked: () => {
    console.log("Exit button clicked");
    this._onMenuLeave(1000);
}

再次保存并测试你的工作以确保它按预期运行。看起来和表现都非常出色,但在我们能够停下来休息之前,还有最后一件事要做。

菜单收尾工作

这里有点太安静了,对于一个应该吸引人并有趣的主菜单屏幕来说。不过,我们可以用音乐的力量来解决这个问题!虽然我们将在后面更详细地介绍播放声音和音乐,但机会难得,所以这里尽可能用最少的字数给出快速且简单的版本:

添加导入语句


import titleMusic from "../assets/sounds/space-trucker-title-theme.m4a";

从构造函数加载并播放音乐


this._music = new Sound("titleMusic", titleMusic, scene, () => console.log("loaded title music"), { autoplay: true, loop: true, volume: 0.5 });

享受这些感觉


<enjoy the music> 

好吧,也许最后一部分有点过于夸张;我们确实想在某个时候停止音乐。在 _onMenuLeaveonEnded 回调中调用 this._music.stop() 以在点击 退出 按钮时停止播放声音。一旦你运行了应用程序并纠正了任何问题,就是时候将更改提交到源代码控制,并享受一杯提神的饮料——我们完成了本章!

摘要

在本章中,我们经历了一段旅程。有些人可能会认为这更像是一场艰难的跋涉,这并不不公平——我们在这里已经艰难地穿越了一些相当密集的材料!尽管有相当多的理论和高级概念被抛出,但回想一下本章所取得的成就——我们最初是从一个启动动画的着陆页开始的。现在,我们有一个启动到 应用程序 的着陆页。

接下来,我们将探讨如何以产生一致和可预测的行为的方式处理接受不同形式和方法的输入问题——请继续关注我们,不要害怕花时间回顾你第一次没有理解的内容。令人惊讶的是,理解需要多次阅读才能真正掌握,但如果这不起作用,你发现自己在理解或跟随上遇到困难,请不要担心。导航到 Space-Trucker 讨论区或 Babylon.js 论坛,向社区发布你的问题或问题——你不是一个人在战斗!

扩展主题

事情正在逐渐积累动力,但这并不意味着没有更多可以探索和扩展的东西!以下是一些你可能想要查看、探索或构建到本章代码中的想法:

  • 创建或扩展 Babylon.js 的常规 Animation 类型功能,以包括 2D GUI 控制 – 或者 – 实现一个模拟 Animation 对象行为的具有 GUI 控制的类。

  • 你能否在 SpaceTruckerLoadingScreen.js 代码中找到缺陷?如果你在脑海中阅读它,这可能会有些微妙,但代码中确实存在逻辑缺陷。运行它不会抛出任何错误,但在某些条件下,它确实会在运行时产生可见的效果。

  • 不要使用单个全屏的 AdvancedDynamicTexture,而是使用一个或多个绘制到场景中网格上的网格附加纹理,这些纹理可以以有趣的方式动画化。

  • 在主菜单显示超过 30 秒且没有用户输入后,添加一个吸引模式。吸引模式是街机游戏的一个功能,它将游戏置于一个非交互式演示模式,旨在吸引路人的注意。你对于吸引模式有什么想法?

第五章:添加场景剪辑和处理输入

我们迄今为止完成的大部分工作都为整个几乎看不见和听不到的整体做出了贡献。我们唯一要求用户执行或甚至监听的动作只是一个按钮点击。多么无聊——而且安静。但这一切都将改变!在本章中,我们将通过添加一个启动屏幕来为我们的应用程序启动增添一些趣味,这个屏幕告诉世界他们即将看到的一切都是“由 Babylon.js 提供动力”的过程,同时为玩家提供他们的第一次 Space-Truckers 体验。我们还将通过添加多种不同设备类型的输入以及将输入处理为游戏中的动作的逻辑,让用户在游戏世界中拥有更多的自主权。

这似乎在如此短的章节中要涵盖很多东西,但多亏了 Babylon.js 中完成任务是多么容易,进度可能会比你想象的要快。

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

  • Space-Truckers – 启动屏幕

  • 设计输入系统

我们将要做的所有事情都将基于我们在之前章节中完成的工作,但如果你只是从这里开始学习,也没有关系——继续阅读以获取完成本章所需源代码的技术细节。

技术要求

这是我们第一次扩展技术要求,但几乎对每个人来说都不会感到惊讶,因为要与特定类型的输入设备一起工作——无论是鼠标和键盘、Xbox™控制器、索尼 PlayStation™控制器,甚至是 VR 摇杆——都需要手头有一个这样的设备,或者(最坏的情况)下载并安装一个合适的模拟器/应用程序。话虽如此,Space-Truckers 应该可以使用以下输入类型进行游戏:

  • 键盘和鼠标

  • 虚拟摇杆/触摸屏

  • Xbox™控制器

  • 索尼 PlayStation™控制器

  • 通用游戏手柄

需要一个合适的音频输出设备来播放音乐和声音。

本章将遵循与之前章节相似的模式,我们将在将它们集成到应用程序的代码库之前构建一个或多个 PlayGround 片段。如果您想找到一个参考点或开始您旅程的地方,起始代码位于github.com/jelster/space-truckers/tree/ch4。现在,事情都安排妥当后,我们可以全神贯注于我们的第一个任务:构建启动屏幕!

Space-Truckers – 启动屏幕

没有什么比一个引人注目的入场更能吸引观众的注意力了,而没有人比那位伟大的威廉·莎士比亚更懂得这一点。翻看他的任何一部戏剧的前几页,就能发现从《罗密欧与朱丽叶》中敌对帮派的街头斗殴到《仲夏夜之梦》中婚礼被打断的众多激动人心的场景。这位剧作家知道如何吸引观众的注意力——在那个时代这是一个显著的成就——就像他毫无顾忌地从历史和神话(有时同时进行!)中掠夺他的故事一样,我们也将毫无顾忌地掠夺他在我们的工作中使用的技巧。

我们将考察的具体灵感来自 S 先生的想法,即吸引观众的注意力,为他们即将体验的内容做好准备。对于太空卡车司机来说,我们没有计划任何华丽的战斗场景或奇幻的婚礼,但我们确实有我们的喷溅屏幕!

在上下文中观察喷溅屏幕,用户刚刚点击了常规 HTML 网页上的启动按钮,将页面过渡到 WebGL,并渲染我们在第二章**,在 Babylon.js中构建的动画加载屏幕。立即在喷溅屏幕完成(无论是运行到结束还是因为用户选择跳过)之后,用户将被带到我们在上一章中构建的主菜单屏幕。通过一系列动画序列和音频配乐的配合,用户将被完全带入太空卡车司机的氛围中。

场景分镜

虽然让思绪飘向可能出现的喷溅场景的潜在途径很容易,但我们将其留作公关活动™,而是从极其简单的东西开始,然后将其用作扩展的基础。分镜板不必是一个极其复杂和计划周详的物品。用于分镜的时间是不在尝试分镜中提出想法的时间,所以不要担心它看起来好不好,要担心的是这些板是否捕捉到了你想要发生的场景的一系列快照。以下图表显示了构成喷溅屏幕分镜的草图系列:

图 5.1 – 喷溅屏幕序列的分镜。由于是草图,给出的时间数字不应直接作为依据

图 5.1 – 喷溅屏幕序列的分镜。由于是草图,给出的时间数字不应直接作为依据

让我们通过按时间顺序走过场景来逐步分析这个图表。在时间=0 时,我们有一个空白的舞台(屏幕)。经过 2 秒钟后,带有“由...提供支持”字样的第一个面板完全可见。在那之后半秒(或 T+2.5 秒)标志着退出子序列的开始,另一个半秒后(或 T+3 秒)面板完全隐藏。第一个面板描述的总时间是 3 秒。有了这个解释,其他三个面板也应该是有意义的。每个面板都推进场景,从左上角开始,向右移动,然后回到左面板。面板中显示的图像根据给定的时序淡入淡出,但那些数字应该仅用作粗略的指南标记——重要的是要调整这些值到你喜欢的程度。

如果你将故事板与最终的启动屏幕序列进行比较,会发现一些明显的相似之处;面板几乎都是相同的,顺序也相同,标注的时间也大致相同,等等。这显示了设计从开始到结束的演变,并有助于强调故事板的一个核心观点——这些板不是全部的故事!这些诚实的草图是为了确定一个基准,为涉及的基本元素和时序提供一个大致的定义,这样我们就可以专注于其他实施方面——比如代码。

构建场景

我们在代码中构建启动屏幕时,需要学习的一个新概念就是我们尚未遇到过的。其他所有内容都将使用我们在前几章中以一种或另一种方式使用过的技术组合,所以希望这看起来相当简单!对于本章的这一部分,我们将专门在PlayGroundPG)中工作——如果你在跟随,这就是你想要加载 PG 并添加新片段的地方。

重要提示

代码列表将继续变得更加完整,更多地关注讨论中的特定方面或代码区域,这些方面或区域是重要的、棘手的或非直观的。本章的完整代码可以在github.com/jelster/space-truckers/tree/ch5找到。不要犹豫,将它拉出来,与你的进度进行比较或检查你的工作——有时候,解释是不够的,你需要看到正在工作的代码!

当我们从故事板中分离出各种动画序列时,场景的立即结构或排序方式会立即显现出来。故事板中的每一块代表场景在特定时间发生的一个独特的快照,因此我们需要想出一个在代码中表示这些场景片段的方法。我们希望它是一个可重用的组件,并且我们希望能够使用 CutSceneSegment 的力量,以及一个新的 SplashScene 类,可以用来组合和管理这些片段,并具有适当的时序和过渡。

CutSceneSegment

CutSceneSegment 类是一个简单的容器,可以表示场景序列的原子部分,尽管它很简单,但并非没有行为。一个 CutSceneSegment 应该能够 startstop 其序列,可能还会循环播放。同样,其他组件可能需要知道何时完成一个片段,因此一个 onEnd 可观察者将使我们更容易编写控制逻辑来管理多个片段的顺序。因为我们不恨自己,也不想花时间调试神秘地表现不佳的代码,所以我们将 CutSceneSegment 的实例视为 不可变。也就是说,一旦我们创建了对象,我们就不会尝试通过替换包含的动画等方式来改变它。

重要提示

你能保密吗?有 JavaScript 经验的人可能会认为“不可变”这个词被错误地应用了。虽然从严格的技术意义上讲,我们处理的对象不是不可变的,但我们的想法是,我们只是假装它是不可变的。如果我们按原样使用它,而且没有人告诉,那么一个对象是否不可变有什么关系呢?但是要小心——很容易混淆软件思考的方式和在代码中表达这些概念的方式,所以不要将此误解为特定语言的指导!

虽然能够在单个 CutSceneSegment 中控制多个目标场景元素会很方便,但我们不需要这种复杂性来实现我们的故事板场景。这个决定与关于不可变性的先前决定相结合,对我们如何编写类的 构造函数 有两个重要的影响。

首先,我们需要获取一个 target,该片段将对其操作。这可以是任何可以动画化的东西,所以,几乎任何你想要动画化的 BJS 类型都可以在这里使用(除了 animationSequence 中的类型。当然,“ctor”(就像酷孩子们称呼的那样)需要引用当前场景,这为我们提供了以下方法签名:


class CutSceneSegment {
    //loopAnimation = false;
    //animationGroup;
    //onEnd = new Observable();
    constructor(target, scene, ...animationSequence) { ... }

你可能不熟悉高亮显示的语言结构。没关系,因为虽然这并不罕见,但它也不是你可能在日常 JavaScript 中遇到的东西。在 animationSequence 前面的三个点 (.) 表示该参数被视为一个任意的 params-style 数组。这仅仅是一块方便的“语法糖”,允许函数的调用者避免创建和传递一个 Array,而是传递由逗号分隔的元素列表。下面的代码片段显示了数组作为后跟的三个参数传递:


new CutSceneSegment(billboard, scene, fadeAnimation,
  scaleAnimation, rotateAnimation);

CutSceneSegment 构造函数中,我们需要完成两个主要任务:

  1. animationSequence 中的每个动画创建一个 TargetedAnimation

  2. 将目标动画添加到一个新的 AnimationGroup 中。

按照相反的顺序,AnimationGroup 是项目中的新功能。不要试图过度思考它——它确实和它的名字所暗示的一样。接下来,因为我们已经有了只需要定位的动画,我们可以遍历 animationSequence 集合,并使用 AnimationGroupaddTargetedAnimation 方法来完成绑定。关于 AnimationGroup 属性和方法的不同方面的更多信息,请参阅 Babylon.js 文档网站 doc.babylonjs.com/divingDeeper/animation/groupAnimations。除了之前的循环逻辑之外,AnimationGroup 的使用与单个 Animation 非常相似。完成这些任务后,构造函数剩下的就是将 CutSceneSegment.onEnd 成员属性委托给 AnimationGroup.onAnimationGroupEndObservable。下面是整个 constructor 的样子:


constructor(target, scene, ...animationSequence) {
    this._target = target;
    let ag = new AnimationGroup(target.name + 
      "-animGroupCS", scene);
    for (var an of animationSequence) {
        ag.addTargetedAnimation(an, target);
    }
    this.animationGroup = ag;
    this.onEnd = ag.onAnimationGroupEndObservable;
    this._scene = scene;
}

完成 CutSceneSegment 类的是 startstop 方法。这些方法非常简单,只是调用 this.animationGroup 的相应函数。当我们想要循环一个 CutSceneSegment(这不是典型的用法)时,我们可以在调用 start 之前将 loopAnimation 标志设置为 true:


start() {
    this.animationGroup.start(this.loopAnimation);
}
stop() {
    this.animationGroup.stop();
}

这就完成了 CutSceneSegment 类。它准备好在即将为 SplashScene 类编写的代码中使用,我们将为故事板中的每个面板创建一个段,然后再按顺序播放它们。不过,首先,让我们为场景添加另一组构建块——驱动场景视觉的动画。

动画

对于场景,我们只需要三种不同的动画类型。关键帧和目标可能不同,但被动画化的基本属性是相同的。在类声明之外,添加 flipAnimationfadeAnimationscaleAnimation 的声明。为了保持帧率相同,我们将 animationFps 声明为 const


const animationFps = 30;
const flipAnimation = new Animation("flip", "rotation.x",
  animationFps, Animation.ANIMATIONTYPE_FLOAT, 
  ANIMATIONLOOPMODE_CONSTANT, true);
const fadeAnimation = new Animation("entranceAndExitFade",
  "visibility", animationFps,
  Animation.ANIMATIONTYPE_FLOAT,
  Animation.ANIMATIONLOOPMODE_CONSTANT, true);
const scaleAnimation = new BABYLON.Animation("scaleTarget",
  "scaling", animationFps, Animation.ANIMATIONTYPE_VECTOR3,
  Animation.ANIMATIONLOOPMODE_CYCLE, true);

到现在为止,这应该已经很熟悉了,除了高亮显示的true参数;这指示 Babylon.js 动画引擎使动画能够与其他动画混合。这并不一定是我们立即在场景中利用的东西,但一开始正确配置它对于将来需要时是很重要的。

重要提示

在 BJS PlayGround 中,IntelliSense 有时会将 BABYLON.Animation 类型与具有相同名称的浏览器或 DOM 类型混淆。添加 BABYLON 前缀可以帮助消除混淆,但记住稍后要移除它——当代码本地集成时,你不需要它。

所有部件都已准备就绪并放置到位,以便我们开始构建 SplashScene 类,我们将创建并组装 CutSceneSegments 以形成一个完整的场景。

SplashScene 类

在设计类或组件的代码结构时,一个好的开始方式可能是简单地识别和捕获任何目前已知的状态变量作为类成员,即使其值将在稍后设置。这样的一个例子就是 currentSegment。这个属性持有当前正在播放的 CutSceneSegment。我们将在构造函数中填充各种片段,但通过在 constructor 外声明成员(而不是在 this.foo = 3 中定义它),我们提高了代码的可读性——这对于任何面向生产的代码来说都是极其重要的!以下是我们想要定义的类成员:

  • currentSegment

  • poweredBy

  • babylonBillboard

  • communityProduction

  • dedication

  • onReadyObservable = new Observable()

  • skipRequested = false

前面的每个片段(除了高亮显示的,很明显的原因)都对应于故事板上的一个面板——按照执行顺序,有助于提高可读性。尽管我们将在本章的稍后部分使用它,但 onReadyObservable 存在是为了表示所有资产都已加载完成,并且剪辑场景准备开始。skipRequested 的情况类似——在本章的稍后部分,我们将添加玩家跳过剪辑场景的能力,所以现在添加它是合理的。由于我们已经在那个区域工作,添加连接它的少量代码也很简单,这又少了一件需要担心的事情。

构造函数中已经有了足够的设置代码,所以一个有前瞻性的人可能会考虑添加方法占位符来封装每个片段的设置过程!将注意力转向 createScene 函数,我们希望尽快看到东西,所以让我们连接逻辑的一端,这将允许我们的片段在彼此之间转换。

就像我们之前的 PlayGround 片段结构一样,SplashScene 构造函数需要一个 BABYLON.Engine 实例作为参数传递,它使用这个实例来创建场景。同样简单的是 createScene 函数,它仅在 PlayGround 中使用。如果需要复习,以下是如何在 createScene 的主体中将代码与 PlayGround 连接起来的方法:


const splashScreen = new SplashScene(engine);
splashScreen.onReadyObservable.add(() =>
  splashScreen.run());
return splashScreen.scene;

我们需要能够离散地控制 SplashScene 的开始和停止,因此构造函数不是开始播放 CutSceneSegments 的地方。相反,我们将添加一个 run 方法(在前面的片段中突出显示),以响应 onReadyObservable 的信号来执行这些任务。现在,随着我们对 SplashScene 进行增强和扩展,我们将能够在此基础上构建,而无需担心所有内容同时开始。

如果你眯起眼睛足够多,并且可能暂时没有保护地盯着太阳看,SplashScreen.run() 函数看起来非常类似于 SpaceTruckerApplication 运行函数中的 run 函数。

重要提示

在没有适当的眼部保护的情况下,不要直接看太阳!即使是有阻挡紫外线功能的太阳镜,对眼睛的保护也不充分,可能会导致永久性损伤。相关的是,永远不要从编程技术书籍中获取户外活动的建议。祝你好运。

它们看起来如此相似的原因是它们都执行类似的功能。面临相似问题,解决方案也相似,所以我们就到这里:


run() {
    this.currentSegment.start();
    let prior, curr = this.currentSegment;
    this.onUpdate = this.scene.onBeforeRenderObservable
    .add(() => {
        if (this.skipRequested) {
            this?.currentSegment.stop();
            this.currentSegment = null;
            return;
        }
        curr = this.currentSegment;
        if (prior !== curr) {
            this.currentSegment?.start();
        }
    });
}

尽管这个场景没有使用我们在上一章中看到的 function* 生成器,但它仍然符合简单状态机的类型。当前状态(由 currentSegment 表示)在每一帧都会被轮询并与前一帧的值进行比较。如果它们不同,那么这意味着一个新的片段已经被交换进来,并且必须调用其 start 方法来继续序列。由于它非常直接,而且因为我们已经在这里,所以还添加了管理玩家希望跳过预告片直接进入主菜单用例的逻辑。唯一真正值得注意的事项是,将 this.currentSegment = null?. 操作符组合起来,以防止对未定义值调用方法;如果 currentSegment 为空(从代码的角度来看),那么预告片要么还没有开始,要么已经结束。

为了提供一个稳定的平台来创建 CutSceneSegments,我们还需要在构造函数逻辑中添加一些内容,如下面的代码所示:


const scene = this.scene = new Scene(engine);
scene.clearColor = Color3.Black();
this.camera = new ArcRotateCamera("camera", 0, Math.PI / 2,
  5, Vector3.Zero(), scene);
this.light = new HemisphericLight("light", new Vector3(0,
  1, 0), scene);
this.light.groundColor = Color3.White();
this.light.intensity = 0.5;
const billboard = this.billboard =
  PlaneBuilder.CreatePlane("billboard", {
    width: 5,
    height: 3
}, scene);
billboard.rotation.z = Math.PI;
billboard.rotation.x = Math.PI;
billboard.rotation.y = Math.PI / 2;
const billMat = new StandardMaterial("stdMat", scene);
billboard.material = billMat;

设置场景、摄像机和灯光现在应该相当标准了,尽管使用billboard摄像机渲染场景,有light照亮billboard,还有billboard显示我们的内容——无论那是什么!我们希望billboard垂直于摄像机的视图,因此设置初始旋转。这些值可能看起来有点奇怪,但很快就会变得有意义。现在我们已经有了渲染剪辑场景的框架,是时候开始定义剪辑场景片段了!我们已经进行了一些操作而没有保存(或者如果你有很好的自律,可能没有),所以现在是运行代码片段并检查任何明显的问题或错误的好时机,然后再保存以备将来参考。

“由我们提供动力”的剪辑场景片段

参考我们的初始故事板,作为我们的第一个片段,我们有一个显示风格化的“由我们提供动力”图像的billboard。时间设置是合理的,而且完全可用。然而,它的问题是它太无聊了。让我们通过在整个片段中缓慢旋转billboard来增加一些趣味,使用我们之前创建的flipAnimation。同时,我们将在适当的时间应用fadeAnimation来淡入淡出billboard。为了保持构造函数的大小在可控范围内,向SplashScene添加一个新的成员函数,并将其命名为buildPoweredByAnimations。然后,在函数体中,首先为片段的每个关键时间事件声明常量:


        const start = 0;
        const enterTime = 2.5;
        const exitTime = enterTime + 2.5;
        const end = exitTime + 2.5;

上述代码片段中的值是通过实验得到的,所以请随意尝试其他值,直到找到适合你的正确值。在计算出绝对时间值后,我们还可以计算每个时间事件相关的帧号:


        const entranceFrame = enterTime * animationFps;
        const beginExitFrame = exitTime * animationFps;
        const endFrame = end * animationFps;

当我们想要定义动画的flipKey值代表目标的旋转y分量时,这些帧号很重要:


const keys = [
            { frame: start, value: 0 },
            { frame: entranceFrame, value: 1 },
            { frame: beginExitFrame, value: 0.998 },
            { frame: endFrame, value: 0 }
        ];
        fadeAnimation.setKeys(keys);
        const flipKeys = [
            { frame: start, value: Math.PI },
            { frame: entranceFrame, value: 0 },
            { frame: beginExitFrame, value: Math.PI },
            { frame: endFrame, value: 2 * Math.PI }
        ];
        flipAnimation.setKeys(flipKeys);

在根据计算出的帧时间定义了每个相关的关键帧之后,将那些关键帧通过调用setKeys传递给动画是很重要的。这符合我们重用Animations的计划,因为关键帧被复制到与目标关联时创建的TargetAnimation实例中;我们只需在需要时再次调用setKeys并使用一组新的关键帧即可。

重要提示

我们为这个CutSceneSegment建立的模式将被用于其余的片段。换句话说,这将在测试中用到!

我们buildPoweredByAnimations函数需要做的最后一件事是创建并返回一个新的CutSceneSegment,将所有内容组合在一起:


const seg0 = new CutSceneSegment(this.billboard,
  this.scene, fadeAnimation, flipAnimation);
return seg0;

SplashScene 构造函数中,我们将调用 buildPoweredByAnimations 函数来创建 poweredBy 对象变量。将 poweredBy 分配给 this.currentSegment 将确保当调用 run 时,序列开始。之后,我们需要加载“由…提供支持”的图像作为纹理,我们可以使用 billMat。由于这涉及到外部图像资产,请添加对图像文件的顶级声明(有关构建资产的完整 GitHub URL 的更多信息,请参阅上一章)。在这个初始情况下,它将是一个名为 raw.githubusercontent.com/jelster/space-truckers/develop/assets/powered-by.png 的文件。使用该 URL 构建一个新的 billMat.diffuseTexture 属性。

重要提示

确保在将其分配给材质之前加载纹理!

在运行时,你应该在广告牌平面上看到图像,这是在保存之前测试你工作的好方法!

转换到下一个 CutSceneSegment…以及更远的地方

CutSceneSegment 开始运行时,它可能会对场景中涉及的不同演员和场景元素的状态做出某些假设。例如,一个以特定模式减弱灯光的照明动画可能需要强度值从特定水平开始。同时,一个给定的部分不能“知道”关于其他部分或它们之间关系的任何信息——尽管有一个至关重要的、但有保留的例外。在 CutSceneSegment 完成后,onEnd 可观察者是理想的解决方案——同时也是一个保留条件!为了在构造函数中方便地保持一些局部变量在作用域内,我们可以调用 onEnd.addOnce(() => { … })。函数体是我们想要整理场景中的对象的地方,同时指定 Splash Scene 序列中的下一个部分:


        poweredBy.onEnd.addOnce(() => {
            console.log("powered End");
            billMat.diffuseTexture = babylonTexture;
            billboard.rotation.x = Math.PI;
            this.light.intensity = 0.667;
            billboard.visibility = 0;
            this.currentSegment = babylonBillboard;
        });

在我们立即的情况下,下一个部分将是 babylonBillboard 部分,所以在 poweredBy.onEnd 处理器中使最后一个语句为 this.currentSegment = babylonBillboard。在表达式之前,我们需要重置 Babylon.js 标志纹理的 billMat.diffuseTexture

重要提示

在继续到下一个部分之前,尝试运行 PlayGround 片段以查看其外观并测试是否存在任何重大错误是个好主意。打开浏览器的开发者工具查看记录的消息可以帮助你获得对时间的感觉!

那是什么?新的部分不存在,buildBabylonAnimation 函数也不存在:对于纹理,使用 raw.githubusercontent.com/BabylonJS/Brand-Toolkit/master/babylonjs_identity/fullColor/babylonjs_identity_color.png 和对于 animationSequence,使用 fadeAnimation

在添加buildBabylonAnimation方法后,请确保在构造函数中调用它,以便您可以订阅新段落的onEnd可观察对象。在babylonBillboard.onEnd处理程序中,由于在此段落中没有移动,因此不需要重新定位 billboard,但需要为下一个段落做好准备,希望这是一个熟悉的节奏。

下一个段落被称为communityProduction,在功能上与上一个段落相同,只是纹理不同,位于raw.githubusercontent.com/jelster/space-truckers/develop/assets/splash-screen-community.png。它也仅使用fadeAnimation。以下是该段落中需要的主要相关时间和数字:

就像上一个段落一样,communityProduction.onEnd处理程序将负责设置下一个段落 – callToAction – 并将billMat.diffuseTexture更改为下一个,由于缺乏更好的名称,我们将称之为rigTexture。这种纹理被渲染到billboard网格上,在淡入后,我们将对其scaling属性应用循环动画,使其看起来更加动态。

重要提示

故事板指示这个面板是版权声明等内容的所在地,但没有任何理由这些内容不能放在其他同样有用但不太突出的地方。相反,我们将使该面板包含一个 Space-Trucker 图像,该图像将以准备等待指示状态缓慢地脉冲缩放和透明度,等待玩家交互。

在不久的将来,我们将添加一些输入管理。为此,我们需要一种显示适当格式化文本的方法。在一个块中。就像一个TextBlock一样。我们的SplashScreen将需要使用BABYLON.GUI

最后一个段落

我们的最终CutSceneSegmentcallToAction – 跟随了与其他相似的路径,即我们使用billBoard来显示一个diffuseTexture,使其淡入场景。在这里,段落开始出现分歧,因为不是再次淡出,我们希望它淡入然后循环,永远不会完全淡出。同时,我们将使用scaleAnimation来改变billboard网格沿其X-和Z-轴的缩放。这将给二维平面图像在动画循环时带来深度和缩放的假象,这意味着它看起来很酷!以下是该段落中每个动画的时间安排:

当达到结束时间时,我们希望我们的行动号召CTA)文本变得可见,邀请我们按下一个键或轻触触摸屏以继续。在巴德最喜欢的技巧之一中,这里有一些预示(不是阴影那种,而是文学上的)——CTA的微妙目的是让应用程序能够弄清楚玩家想使用哪种输入。这是两个实体之间的一种非常直接的联系手段,否则它们几乎没有任何能力理解对方,而且它之所以有效,是因为它的二进制(讽刺!它燃烧!)简单性通过玩家拿起设备并参与输入来传达用户的偏好。

在我们继续之前,我们需要通过创建前面提到的BABYLON.GUI.AdvancedDynamicTexture来完成构造函数的实现:callToActionTexture。创建、配置 GUI 的属性并向其中添加TextBlock现在已经成为一项熟悉的练习(尽管在第十章“通过光照和材质改进环境”中,我们将介绍 GUI 编辑器!),所以接下来的列表应该不需要太多解释:


// ... create billboard textures used in segments
let callToActionTexture = 
    this.callToActionTexture =
      BABYLON.GUI.AdvancedDynamicTexture.
      CreateFullscreenUI("splashGui");
let ctaBlock = new TextBlock("ctaBlock", 
    "Press any key or tap the screen to continue...");
ctaBlock.textWrapping = BABYLON.GUI.TextWrapping.WordWrap;
ctaBlock.color = "white";
ctaBlock.fontSize = "16pt";
ctaBlock.verticalAlignment = 
    ctaBlock.textVerticalAlignment =
      TextBlock.VERTICAL_ALIGNMENT_BOTTOM;
ctaBlock.paddingBottom = "12%";
ctaBlock.isVisible = false;
callToActionTexture.addControl(ctaBlock);
// ... call the builder functions
// ... Attach onEnd delegates

有一样东西不要忘记,那就是将ctaBlock(突出显示)的初始可见性设置为false。如果你想在callToAction.onEnd的处理程序之前显示它,那就去做吧——这是你的游戏!一旦你将所有内容添加到构造函数中,就试试看并修复出现的任何错误。点击保存,然后确保你戴上耳机或以其他方式提高电脑的音量——是时候加入主题曲了!

淡入标题音乐

到现在为止,我们已经对SplashScene进行了这么长时间的工作,它可能开始显得有点平淡,而这正是我们不能再接受的事情。在第四章“创建应用程序”中,我们将太空卡车手主题曲添加到了SplashScene的转折处。

回想一下,你希望不是很久以前,你读过这个亮点吗?

“尽管我们将在本章的后面部分才会使用它,但onReadyObservable用于指示所有资源已加载完成,场景准备开始。”

好吧,“本章后面的部分”现在就开始。由于我们已经将其他所有东西都安排妥当,现在只剩下四个任务来完成这个家伙并带它回家:

  1. raw.githubusercontent.com/jelster/space-truckers/develop/assets/music/space-trucker-title-theme.m4a添加一个字符串来保存歌曲的 URL(或替换你自己的)。

  2. readyToPlayCallback中创建一个新的SplashScene.onReadyObservable.notifyObservers。将音量设置得非常低——0.01效果很好——以给音量增长留出空间。

  3. SplashScene.run方法中添加对this.music.play()的调用。

  4. 通过调用 this.music.setVolume(0.998, 500) 在一段时间内提高音量(也在 run 方法中)。

执行常规的运行、修复问题、重复所需操作,然后保存。如果您遇到麻烦或想比较您的结果与已知的“工作”片段,请查看 playground.babylonjs.com/#DSALXR。仍然似乎无法让事情正常工作?前往 Space-Truckers GitHub 讨论板 github.com/jelster/space-truckers/discussions,从社区获得帮助,留下反馈或错误报告,并获取自本书出版以来的代码更新。在 PG 中有一个可运行的示例,可以帮助您探索想法和概念,但现在,是时候象征性地摘下我们更抽象和理论的游戏设计师帽子,戴上我们更具体和务实的软件工程师工作头盔——在我们将 PG 代码与应用程序集成时,我们将需要这些品质。

集成 SplashScene

工作集成阶段是光鲜亮丽的优雅 PG Snippet 与现实世界中冷酷丑陋的真相相遇的地方。这是事情最有可能出错的地方,也是应用程序代码中的错误被揭露的地方。这种情况发生的原因与编写代码的人的性格和属性关系不大,尽管有时可能会感觉是这样。在这个阶段发现的任何错误或缺陷都是对原始代码编写时未知内容的反映,这意味着有机会对其进行改进!

看到差异

由于您有这份文本的帮助来指导您的努力,您将免于追踪和修复在 SpaceTruckerApplication.js 组件中发现的两个问题,以及我们将对类结构进行的其他一些更改。包括上述两个问题,以下是我们需要完成以集成 SplashScreen 的事项列表:

  • 将新文件添加到 /src - cutSceneSegment.jssplashScene.js

  • 在新文件中添加适当的导入,并复制类定义

spaceTruckerApplication.js 文件将经历这些任务带来的最大变化:

  • 移除在 spaceTruckerApplication 中用于模拟加载时间的占位符 Promises。随着这些占位符的消失,我们也可以从它们的主函数中移除 async 标识符。

  • initialize 方法中实例化场景,而不是之前的地点。

  • goToOpeningCutscene 中注册一个监听 onReady 事件的观察者。

最后,以下是两个本应阻止应用程序正确进展和渲染的问题:

  • (问题)AppStateMachine 应该产生 currentState

  • (问题)在engine.runRenderLoop回调中的逻辑需要是一个类级别的函数,以便正确访问this。可以通过将箭头函数提取为类级别函数来解决这个问题——即this._engine.runRenderLoop(() => this.onRender());

最直观地查看更改的方式是将启动屏幕查看为应用程序的一部分。

无论如何访问,我们需要比较的修订范围可以用ch4...6db9f7e表达式表示。将其用作git diff的参数,或者将其粘贴到浏览器中作为<repo URL>/compare/<修订范围>的尾随路径,或者在这种情况下,github.com/jelster/space-truckers/compare/ch4...6db9f7e

根据你的开发环境的具体情况,差异将以不同的方式显示。无论具体工具如何,几乎每个差异都会根据给定修订范围内的单个已更改文件组织其报告。VSCode 的 时间轴功能将显示打开文件的提交历史;可以通过点击时间轴面板中的修订版本来查看差异。

小贴士

在每次提交或合并之前养成仔细检查这些差异的习惯可以提高你的编码能力,以及代码的质量。一个迹象表明你可能在一个提交中做了太多事情,就是有一个复杂且长的更改集。将工作分解成更小的组件,并分别提交,这样不仅任何拉取请求PR)的审阅者会感谢你,而且你会发现自己在更快、更有信心地前进。

GitHub 的网页界面在查看修订版本分支甚至分叉(也称为上游仓库)之间的差异时也非常有用。对于希望成为软件开发高手的人来说,导航和理解不同的报告是一项关键技能,但面对如此多的信息,不可避免地会产生噪音,这可能很困难。GitHub 会尝试为你做些这方面的工作,例如默认情况下折叠大的差异,但不幸的是,处理信号与噪音比不佳的方法并不是事后可以追溯的;它只有在提交推送时应用才有效。这个解决方案是从一开始就注意并构建具有高信号与噪音比的提交。以下是一些有助于此的建议:

图片

当需要时,将差异用作参考指南,尝试独立完成之前列出的活动。当然,既然你已经在查看差异,如果你只想立即继续,可以自由地拉取提交 6db9f7e的代码。以下图显示了运行应用程序、点击启动按钮以及结束启动屏幕后你应该到达的位置:

图 5.2 – 启动屏幕完成并等待用户输入

图 5.2 – 启动屏幕完成并等待用户输入

我们很快就会深入探讨那个提交的补丁中包含的所有项目 – 一些熟悉,一些新项目 – 的细节,但在我们这样做之前,让我们快速回顾一下到目前为止我们已经取得的成果。

从一组故事板面板开始,这些面板展示了场景在不同时间点的快照,我们使用这些面板来确定各种动画和过渡的时间。然后,我们编写了一些可重用的代码来定义 CutSceneSegment,以及其他与动画对象相关的逻辑。最后,我们编写了包含 SplashScreen 类及其相关的资产和 CutSceneSegment 协调逻辑的 SplashScreen 类,这构成了场景的全时间线。这是一项很大的成就 – 不要忽视这一点!

接下来,我们将继续探讨游戏开发中不太受重视的一个领域:输入系统。鉴于其重要性,我们将在本章的剩余部分详细介绍 Space-Truckers 输入系统的功能和实现方式。

设计输入系统

用户界面UI)的主题通常非常重视视觉元素、布局和设计。对于大多数网络应用来说,跟踪指针、触摸或点击以及键盘输入的基本功能由网络浏览器处理,它反过来将许多责任委托给底层操作系统OS)。当使用像 Babylon.js 这样的网络原生应用程序库时,开发者可以利用这些已经存在的抽象来快速轻松地将用户交互元素添加到他们的场景中。在本节中,我们将学习如何添加可以动态支持多种输入的应用程序框架,然后实现将任意输入映射到游戏中的动作或命令的方法。

据说,模仿是最真诚的赞美形式,所以让我们通过“窃取”(在礼貌的场合被称为“研究”)Babylon.js 团队的相机输入管理代码来赞美他们。以 FreeCamera 为例(了解更多信息请参阅 github.com/BabylonJS/Babylon.js/blob/master/packages/dev/core/src/Cameras/Inputs/freeCameraGamepadInput.ts),以下是控制器和应用程序之间数据流的方式:

图 5.3 – 从人类输入设备(HID)通过网络浏览器的 API 流入,到 HTML/Canvas,再到 Babylon.js 以及 FreeCamera 输入系统的各个组件

图 5.3 – 从人类输入设备(HID)通过网络浏览器的 API 流入,到 HTML/Canvas,再到 Babylon.js 以及 FreeCamera 输入系统的各个组件

数据从顶部开始,由设备本身发送数据到连接的主操作系统,该操作系统(通过其设备驱动程序接口)将原始输入数据转换为与网络浏览器或本地主机接口兼容和熟悉的结构。最终,它进入 Babylon.js,在那里它被整理、处理、过滤并传递,直到达到其目标:FreeCamera。以下是一个 PG,它代表我们将要讨论的实际输入系统的略微简化版本——如果您迷路了,请将其用作工作参考:playground.babylonjs.com/#78MJJ8#64

定义主菜单控制方案

尽管我们现在不会为游戏阶段定义控制映射,但我们将使用此模式建立的基石将使添加任何任意控制映射变得快速、轻松且容易,当它们成为必要的时候。该表显示了我们在菜单系统中感兴趣处理的各个输入和动作:

![Figure 5.4 – 将菜单控制映射到各种输入img/Figure_5.04_B17266.jpg

图 5.4 – 将菜单控制映射到各种输入

当涉及到基本的键盘和(鼠标)指针交互时,Babylon.js 的onKeyboardObservableonPointerObservable属性允许订阅者分别通知键盘和鼠标(触摸)交互。GamepadManager(可通过场景的gamepadManager属性访问)和VirtualJoystick在添加游戏手柄及其虚拟触摸等效物时很有用,当鼠标和键盘不是目标时。您可以在 Babylon.js 文档中了解更多信息,请参阅doc.babylonjs.com/divingDeeper/input/virtualJoysticksdoc.babylonjs.com/divingDeeper/input/gamepads

重要提示

如前所述,输入处理的话题足够复杂,以至于需要占用这些页面有限的很大一部分空间来逐行审查所有代码,所以列出的代码将在特定讨论区域的各个部分中被突出显示。尽管如此,您不必担心无法跟上,您仍然可以检查完整的源代码,并且 PG 片段的链接也不会消失!

映射输入数据

尽管上一节中的控制表在游戏或应用程序的用户手册中会很好地工作,但如何利用该表中的信息在这个应用程序中并不那么清晰。

在 JavaScript 中映射foo[“property”]),索引将在我们称之为inputActionMaps.js的新源文件中表示。在其中,我们将定义所有与映射输入到动作相关的各种对象常量和辅助函数:


const inputControlsMap = {
    /* Keyboard Mappings */
    w: 'MOVE_UP', 87: 'MOVE_UP',
    s: 'MOVE_DOWN', 83: 'MOVE_DOWN',
    a: 'MOVE_LEFT', 65: 'MOVE_LEFT',
    d: 'MOVE_RIGHT', 68: 'MOVE_RIGHT',
    //...
    PointerTap: 'ACTIVATE',
    //...
    button1: 'ACTIVATE', buttonStart: 'ACTIVATE',
    buttonBack: 'GO_BACK', button2: 'GO_BACK',
    dPadDown: 'MOVE_DOWN', lStickDown: 'MOVE_DOWN',
};
export default { inputControlsMap, ...};

在左侧(属性名称或button1buttonStart成员。尽管看起来重复且冗余,但在实际设备代码和处理它们的逻辑之间有一个间接层,这为系统提供了大量的灵活性。

当涉及到处理各种类型的游戏手柄输入时,间接引用再次派上用场。BABYLON.DeviceType枚举定义了每个支持的游戏手柄设备的常量。我们将使用另一个对象映射来存储每个特定设备的输入如何与我们的inputControlsMap定义相匹配:


const gamePadControlMap = {
    /* deviceType */
    2: [
        { 0: 'button1' }, // BABYLON.Xbox360Button.A
        { 1: 'button2' },
        { 2: 'button3' },
        { 3: 'button4' }
    ]
};

上述代码展示了 Xbox360 控制器映射在非常基础的层面上的样子。正如注释所示,deviceType数组中的每个对象都对应控制器上的不同输入索引。

接下来,我们将学习如何在运行时使用这种映射信息来解析来自连接设备的输入,但首先,让我们稍微退后一步,以获得更广阔的视角——不要退得太远,我们不想被所有这些内容压垮!以下图表说明了我们需要解决的不同关注点,以便能够处理《Space-Truckers》中的输入:

![Figure 5.5 – 处理输入 第一部分/4。本节涵盖了将来自多个设备和类型的输入数据映射到标准化的结构中,这些结构可以解析为游戏或应用级别的操作]

![Figure 5.05_B17266.jpg]

![Figure 5.5 – 处理输入 第一部分/4。本节涵盖了将来自多个设备和类型的输入数据映射到标准化的结构中,这些结构可以解析为游戏或应用级别的操作]

仅关于设计输入模型等话题,就能写出整本书,但这里要吸取的重要信息是,我们正在编写或即将编写的代码的目标是隐藏(或抽象)输入处理细节,使其从游戏核心逻辑中分离出来。游戏逻辑并不关心或需要知道用户是想用键盘还是手柄来移动他们的卡车——它只需要知道用户想要移动他们的卡车以及移动的方向!

输入管理

当涉及到管理特定的输入和设备时,SpaceTruckerInputManager(请参考代码github.com/jelster/space-truckers/blob/ch5/src/spaceTruckerInput.js)负责管理订阅和取消订阅设备事件等低级设备管理任务,从底层 Babylon.js 输入抽象层检索输入,并将其准备用于处理成动作。

合并,或从多个设备聚合输入,可能既棘手又繁琐——这对需要集中注意力和回忆的编码来说不是最好的组合。通过分解复杂性来解决棘手的问题是第一步;第二步在某种程度上(或许可以说是讽刺性地)比第一步更复杂,因为更多地取决于个人找到方法继续前进到终点。

处理输入

在潜在软件设计方面需要做出的最常见决定之一是(在代码中)将各种责任分配在哪里。有时为了方便,可能会倾向于将所有逻辑、数据和代码都放入一个单独的文件中,但除非这一切都在 PG 中进行,否则增强和维护应用程序将很快在所有实际方面变成一个无法控制的噩梦。

SpaceTruckerInputManagerSTIM)管理复杂性的方法之一是维护单独的、针对特定设备的注册逻辑。不同的设备以不同的方式呈现其数据;某些类型的输入适合订阅可观察对象以接收输入事件:

图 5.6 – 事件传播的可观察对象 – 在此情况下为 onKeyDownObservable

图 5.6 – 事件传播的可观察对象 – 在此情况下为 onKeyDownObservable

另一些则更适合在每一帧的基础上轮询其状态:

图 5.7 – 模拟输入(摇杆轴、触发器等)需要轮询以获取设备当前状态

图 5.7 – 模拟输入(摇杆轴、触发器等)需要轮询以获取设备当前状态

为了使事情更有趣,许多设备混合了范式,一些输入通过可观察事件暴露出来,而一些则只能通过轮询获得!所有这些数据都汇总到一个 inputMap 哈希表(又出现了!)中,其中包含所有已注册输入的当前状态。

输入管理器必须根据礼仪优雅地处理任何一种情况,因此它应该这样做。在订阅者离开后留下悬挂的订阅被认为是不良的礼仪,因此我们必须确保输入管理器也像一位好客人一样清理自己的东西。这意味着我们需要跟踪我们的订阅及其来源,以便我们可以使用 Observable.remove。幸运的是,我们还有一个并行需求,即输入管理器需要能够访问给定的场景。

输入注册

无论何时一个 SpaceTruckerInputManager,无论谁在调用 registerInputForScene,它只需要将 sceneToRegister 添加到其 inputSubscriptions 数组中。添加到列表中的对象映射以注册的场景为键,因为 SpaceTruckerInputManager 的生命周期遵循 Scene.onDisposeObservable(突出显示)。订阅数组包含返回的 enableKeyboardenableMouseenableGamePad 的集合):


registerInputForScene(sceneToRegister) {
    logger.logInfo("registering input for scene",
      sceneToRegister);
    const inputSubscriptions = this.inputSubscriptions;
    const registration = {
        scene: sceneToRegister, subscriptions: [
            this.enableKeyboard(sceneToRegister),
            this.enableMouse(sceneToRegister),
            this.enableGamepad(sceneToRegister)
        ]
    };
    sceneToRegister.onDisposeObservable.add(() =>
      this.unregisterInputForScene(sceneToRegister));
    inputSubscriptions.push(registration);
    sceneToRegister.attachControl();
}

上述提到的设备启用函数返回一个具有非常特定形状的对象——而这个形状是使一切顺利结合的关键之一(请原谅这里的双关语)。

检查输入

为了处理需要轮询的输入,每种设备都需要有一个 checkInput 方法,该方法知道如何检索输入并将其放置到 SpaceTruckerInputManager.inputMap 哈希表中。对于仅利用可观察值在其输入表面化的设备,checkInput 函数可以是一个空操作或空函数,不执行任何操作。具有混合或仅轴输入(例如,摇杆、操纵杆、扳机——任何返回的输入不是总是 0 或 1 的输入类型)的设备实现 checkInput 以在每次调用时(每帧)读取游戏手柄的状态。由于诸如归一化输入等问题是不同游戏手柄模型共享的担忧,inputActionMap.js 中的实用函数(在以下代码块中引用为 SpaceTruckerControls)被利用以确保轴输入值在范围 -1 <= value <= 1 内。其他函数接受这些归一化值并将它们映射到特定的输入方向,这取决于输入是否超过阈值值:


const checkInputs = () => {
    const iMap = this.inputMap;
    if (!this.gamepad) { return; }
// handle quantitative or input that reads between 0 and 1
//(on/off) inputs are handled by the onButton/ondPad Observables
    let LSValues = SpaceTruckerControls
        .normalizeJoystickInputs(this.gamepad.leftStick);
    SpaceTruckerControls
        .mapStickTranslationInputToActions(LSValues, iMap);
    let RSValues = SpaceTruckerControls
        .normalizeJoystickInputs(this.gamepad.rightStick);

    SpaceTruckerControls
        .mapStickRotationInputToActions(RSValues, iMap);
};

上述代码块来自 spaceTruckerInput.js,并作为 enableGamepads 方法中定义的 checkInputs 函数对象的一部分。对于任何类型的模拟输入设备,输入中都会有一定程度的精度和噪声。为了处理这个问题,输入被“归一化”(也就是说,报告的值在范围 -1 <= x <= 1 内)使用静态方法。

处理输入订阅

enableDevice 合同的另一个属性是 dispose 方法。这是一个函数,就像 checkInputs 一样,它包含所有必要的特定逻辑来取消订阅任何观察者并清理自身。这两个属性允许 inputManager 的消费者完全不了解输入是如何被应用程序收集的具体细节。这使得代码更简单,并让我们有更多精力关注其他事情(例如,完成本章的剩余部分)。这就是 enableGamepad 方法的返回值看起来像这样:


return {
            checkInputs,
            dispose: () => {
                this.gamepad = null;
                manager.onGamepadConnectedObservable
                   .remove(gamepadConnectedObserver);
                manager.onGamepadDisconnectedObservable
                   .remove(gamepadDisconnectedObserver);
            }
        };

所有关于观察者、可观察的和订阅的讨论可能会让人困惑。这就是你正在品尝的复杂性,但希望随着我们讨论 getInputs 方法的最后一部分,这种味道会转变为更令人愉悦的强大、功能性的风味。

getInputs 方法

虽然我们希望场景每帧都检查输入,但我们还没有定义将调用该逻辑的是什么,或者它将在应用程序中的哪个位置发生。因为 getInputs 函数将场景作为其唯一参数。sceneInputHandler 本地常量。sceneInputHandler.subscriptions 数组中的每个订阅都会在 forEach 循环中调用其 checkInputs 函数;回想一下,每个订阅代表一种特定的输入类型,而 checkInputs 函数会将最新的值填充到 SpaceTruckerInputManager.inputMap 中。

由于 inputMap 包含了屏幕的所有各种输入,因此会迭代一个条目数组并将其映射到一个包含 lastEvent 属性的输入事件结构中:


getInputs(scene) {
    const sceneInputHandler = this.inputSubscriptions
          .find(is => is.scene === scene);
    if (!sceneInputHandler) {
        return;
    }
    sceneInputHandler.subscriptions
        .forEach(s => s.checkInputs());
    const im = this.inputMap;
    const ik = Object.keys(im);
    const inputs = ik
        .map((key) => {
            return { 
action: controlsMap[key], 
lastEvent: im[key] 
            };
        });
    if (inputs && inputs.length > 0) {
        this.onInputAvailableObservable
            .notifyObservers(inputs);
    }
    return inputs;
}

然后将生成的输入数组返回给调用者,并通过 onInputAvailableObservable(目前未使用)进行分发。注意这个讨论中的大缺口,即关于在哪里以及谁调用 getInputs 函数的问题。这确实是一个好问题,但并不是 SpaceTruckerInputManager 需要关心的问题——这是下一个主题的内容:输入处理:

图 5.8 – 到目前为止涵盖的四个组件中的两个

图 5.8 – 到目前为止涵盖的四个组件中的两个

输入处理

将原始输入映射到游戏或应用程序输入是输入管理的关键部分,这是我们迄今为止涵盖的输入系统的两个组件。这可能对于相对简单的应用程序或游戏来说已经足够了,但 Space-Truckers 有不同的需求。它需要能够选择性地将输入路由到屏幕,而不需要了解该输入的任何细节。它还需要处理输入状态——不仅仅是当前的,还包括过去的。

有一个点,向一位杰出的房客提出额外的请求变得不礼貌,如果我们的房客是 SpaceTruckerInputManager,那么要求它承担这些责任就是……好吧,这实在是太多了。我们需要另一个组件来承担这个负担:SpaceTruckerInputProcessor

附加控制

与其兄弟组件 SpaceTruckerInputManagerregisterInputForSceneunRegisterInputForScene 方法类似,attachControldetachControl 函数。然而,与它的兄弟不同,STIP 函数不接受一个调用 registerInputForSceneSpaceTruckerInputProcessor.attachControl 方法:


attachControl() {
    if (!this.controlsAttached) {
        this.scene.attachControl();
        this.inputManager.registerInputForScene(this.scene);
        this.onInputObserver =
          this.inputManager.onInputAvailableObservable
        .add((inputs) => {
           this.inputAvailableHandler(inputs);
        });
        this.controlsAttached = true;
    }
}

此外,作为将控制权附加到屏幕的一部分,inputManager.onInputObservable 被订阅了 SpaceTruckerInputProcessor.inputAvailableHandler,以便在接收到一组新的输入时得到通知。这是一个简单的小方法,它只是将接收到的输入推入 inputQueue,该队列作为 update 方法的一部分进行处理。

更新

这就是魔法发生的地方。在快速检查以确保可以处理输入之后,调用inputManager.getInputs,这反过来又触发一个函数外的过程,最终将信息填充到inputQueue中。这可能不会及时完成更新函数逻辑的其他部分,但这没关系,因为它将在下一帧中处理:


update() {
    if (!this.controlsAttached) {
        return;
    }
    this.inputManager.getInputs(this.scene);
    this.lastActionState = this.actionState;
    const inputQueue = this.inputQueue;
    while (inputQueue.length > 0) {
        let input = inputQueue.pop();
        this.inputCommandHandler(input);
    }
}

当前将动作映射到状态(this.actionState)的映射被复制到this.lastActionState中,以保留它供后续处理输入时使用。然后,inputQueue中的项目逐个被清空并由inputCommandHandler分发。

输入命令处理器

这种表面上看似简单的方法实际上做了很多看似不可能的事情。这都归功于(第三次总是幸运!)actionMap的力量。actionMap类成员是一个对象映射,它将游戏动作(ACTIVATE)与宿主屏幕中的可执行函数相关联——这是一个我们很快就会深入探讨的话题——它使用它来查找和调用与给定动作相关联的游戏逻辑:


inputCommandHandler(input) {
    input.forEach(i => {
        const inputParam = i.lastEvent;
        const actionFn = this.actionMap[i.action];
        if (actionFn) {
            const priorState = this.lastActionState 
                ? this.lastActionState[i.action] : null;                  
            this.actionState[i.action] =
              actionFn({priorState}, inputParam);
        }
    });
}

按照惯例,我们传递一个包含lastActionState的对象,以及从inputManager传递过来的事件对象,并将返回值存储在之前提到的对象映射actionState中。每个单独的actionFn决定返回什么,以及如何处理传入的状态值,而无需inputProcessor处理具体细节——既整洁又方便!

构建动作映射函数

buildActionMap所做的是所谓的actionListactionDef.action字符串属性用于在SpaceTruckerInputProcessor.screen对象中查找具有相同名称的函数:


buildActionMap(actionList, createNew) {
    if (createNew) {
        this.actionMap = {};
    }
    actionList.forEach(actionDef => {
      const action = actionDef.action;
        const actionFn = this.screen[action];
        if (!actionFn) {
            return;
        }
        this.actionMap[action] = actionDef.shouldBounce() ? 
             bounce(actionFn, 250, this) : actionFn;
    });
}

如果找到,actionMap将在可选地用预处理bounce函数包装后填充找到的函数,以防止它在给定时间段内被调用过多……这把我们带到了我们输入系统的最后一个组件:动作处理:

图 5.9 – 输入处理、输入管理和输入数据映射已覆盖。仅剩动作处理

图 5.9 – 输入处理、输入管理和输入数据映射已覆盖。仅剩动作处理

动作处理

我们在过去的几页中深入探讨了多个层次的抽象和间接,现在我们终于到了所有这一切开始发挥作用的地方——动作处理。虽然之前的步骤仅限于特定的类或实例类型,但动作处理器本身就是屏幕。

习惯性动作

听起来像是一个糟糕的 2000 年代初期的封面乐队名字,但这是一个术语,用于描述我们在特定屏幕上命名和描述动作函数的方式。这比听起来简单得多:对于每个state参数的动作。如果您需要获取有关输入事件的更多信息,请向函数添加第二个参数以接受inputEvent

重要注意事项

记住,动作是游戏特定的概念,例如 MOVE_UPACTIVATE。这些只是为这个游戏命名的名称;你可以随意命名它们!

SpaceTruckerMainMenuScreen 为例,MOVE_UPMOVE_DOWN 动作应该增加或减少菜单项的 selectedItemIndexACTIVATE 动作应该调用菜单项。以下是我们在编码 MOVE_UP 动作时的样子:


MOVE_UP(state) {
    logger.logInfo("MOVE_UP");
    const lastState = state.priorState;
    if (!lastState) {
        const oldIdx = this.selectedItemIndex;
        const newIdx = oldIdx - 1;
        this.selectedItemIndex = newIdx;
    }
    return true;
}

类似地,ACTIVATE 在通过调用其 onPointerClickObservable.notifyObservers 方法来模拟点击事件之前,检索 selectedItem

跳过启动屏幕

作为在本章早期构建启动屏幕的一部分,我们在场景中添加了一个 skipRequested 标志,但从未有任何东西会改变这个值……直到现在!ACTIVATE 动作不需要知道按下了哪个键——它只需要知道它确实发生了;只是最初按下了键。这使得这部分逻辑相当简单:


ACTIVATE(state) {
    const lastState = state.priorState;
    if (!this.skipRequested && !lastState) {
        logger.logInfo("Key press detected. Skipping cut
          scene.");
        this.skipRequested = true;
        return true;
    }
    return false;
}

SpaceTruckerSplashScreen.update 函数中,调用了 actionProcessor.update 函数,反过来,在 SpaceTruckerApplication.Render 中调用,但仅当它是当前活动屏幕时。

![图 5.10 – 输入系统的所有四个组件图 5.10

图 5.10 – 输入系统的所有四个组件

摘要

回顾本章前面的页面,可能会觉得我们并没有完成太多,但不要低估自己——本章中我们讨论的内容并不容易理解或掌握!构建和编排 SplashScreen 的顺序开始增加我们代码的复杂性,不计页面上从那转向输入所引起的心理冲击。

如前所述,并且现在不再重复,关于输入处理的主题可以写整整厚厚的教科书,而我们试图将其压缩到那部分的一小部分。不仅如此,我们现在能够以更清晰的画面来处理未来的功能,了解所有非游戏特定任务的管理和处理方式。

该陈述可以扩展到涵盖本节中的这一章和其他章节——我们现在已经建立了大部分支持应用程序,这让我们有更多精力关注下一节中的主题!在第二节中,我们将构建游戏机制,设置光照和材质,以及更多更多。

扩展主题

从哪里开始?有如此多的有趣事物和可能性可以探索!以下是一些你可以做的事情来进一步学习和增强 SplashScreen

  • 在开始部分添加相机动画,使相机沿着路径移动和旋转,这样广告牌面板的可见尺寸将以大约与音乐音量上升相同的速率增长

  • 向场景添加背景环境效果,类似于我们在主菜单和程序星系纹理中所做的那样

  • 将最终静态图像替换为网格、纹理、材质或其他内容

输入系统也是一个很好的灵感来源。以下是一些可以考虑的点:

  • 实现对您最喜欢的游戏手柄或摇杆设备的支持。使用此测试网站查看您的设备发出的各种输入和值:luser.github.io/gamepadtest/

  • 修改输入系统以允许多个同时连接的用户——即本地多人游戏

  • 将摇杆灵敏度设置暴露给应用程序,以便玩家可以在游戏中编辑

第二部分:构建游戏

在本书的第二部分,我们将基于第一部分的基础来实施构成《太空卡车手:视频游戏》的主要组件。随着内容的增多,速度加快,因为要涵盖的材料远多于容纳的空间。

本节包括以下章节:

  • 第六章, 实现游戏机制

  • 第七章, 处理路线数据

  • 第八章, 构建驾驶游戏

  • 第九章, 计算和显示得分结果

  • 第十章, 通过照明和材质改善环境

第六章:实现游戏机制

前几章的重点是稳固地构建Space-Truckers的应用基础。现在,是时候换挡(如果可以这样说的話)并看看我们想要如何实现游戏的第一阶段:路线规划。面对一个单一、令人畏惧、复杂的问题时,我们通常会将其分解为两个主要方面:模拟和游戏元素。在本章中,我们将首先查看游戏的模拟部分,然后在模拟之上叠加游戏机制,这样我们可以自由迭代。

这是现实秀和游戏秀中常见的策略,主持人会发表一段听起来像是即将揭示重大秘密的演讲,但随后节目却切换到广告。这与我们的情况相关,因为我们将执行类似的误导——我们不会直接进入激动人心的模拟和游戏机制,而是首先进行短暂的偏离,以便我们了解如何在 Space-Truckers 中管理音乐和声音。虽然时间不长,但当我们继续将更多功能集成到应用程序中时,这将是一个实用的补充。

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

  • 声音管理的偏离

  • 设计路线模拟的游戏元素

  • 创建轨道力学模拟

  • 定义规则 – 游戏机制

技术要求

本章的第一部分涉及音频文件和播放它们,因此拥有扬声器或其他听声音输出的方式是有帮助的,但不是必需的。像往常一样,源代码可在 GitHub 上找到:github.com/jelster/space-truckers/tree/ch6

如你所预期,大多数来自前几章的技术要求都适用于本章,因为我们正在继续在那里开始的工作。

有用的知识

以下是一些有用的知识:

  • 在软件技术的领域之外,还有一些概念和技能在进入这一部分之前需要具备一定的了解。如果你不认识或不熟悉这些内容,请不要担心——这正是你最初阅读这本书的原因——为了学习!这包括三维空间中的向量运算,如加法、减法、乘法等,以及归一化(1-单位)向量与非归一化向量的区别。

  • 基本运动学物理——基于时间计算某物的速度或位置,包括和不包括加速度。

  • 熟悉力和动量关系。

注意

计算机应该擅长处理数字,而不是你。如果你不认为自己擅长数学,请不要惊慌——我们已经为你准备好了!

来自 Babylon.js 文档的有用链接

以下是一些有用的链接:

声音管理的另辟蹊径

在我们的旅程中,之前已经提到过播放音乐和音效的话题——主题歌曲声音是作为我们在第五章“添加剪辑场景和处理输入”中构建的启动屏幕的一部分播放的。声音播放得很正常,一切似乎都工作得很好,那么为什么需要无端地使事情变得更复杂呢?这是一个很好的问题,因为在软件中,最好的方法往往也是最简单的,简单是好的,因为它意味着出错的可能性更少(按定义)。在软件中,出错的可能性更少,修改、添加和改进就变得既容易又便宜,这对工程和会计来说都是好事——一石二鸟的特别优惠!

所有这些最终想要表达的是,尽管在独立情况下,将BABYLON.Sound实例直接加载并直接在屏幕上播放是可行的,但当涉及多个场景和屏幕时,问题就会出现。主要原因在于AudioEngine与场景独立,而声音则不是。当我们想要协调多个不同场景中多个声音的启动、停止和音量时,这就会引发问题。

声音、音轨和音频引擎

类似于如何使用WebGL2/WebGPU画布进行渲染,Babylon.js 中使用的底层音频引擎基于Web Audio规范。如果您对此感兴趣,并且/或者如果您有失眠,请查看webaudio.github.io/web-audio-api/。要了解更多关于该规范之上构建的抽象的详细信息,相关的 Babylon.js API 文档可以在doc.babylonjs.com/typedoc/classes/babylon.sound找到。

在音频方面,我们需要将应用程序能够做到以下事情或具有以下特性进行总结:

  • 我们需要能够控制相关类型声音组的总体音量(增益)级别,例如背景音乐、UI 反馈声音和游戏音效

  • 应该能够轻松更换底层的音频资产,而无需更改任何消费组件的代码

  • 音频组件的消费者应该能够轻松访问底层的 BABYLON.Sound

  • 应该协调音频资产的异步加载,以确保在发出准备就绪信号之前,场景的所有资产都已完成任务

为了完成第一项任务,我们将利用 BABYLON.SoundTrack 的功能。这种类型名称很好地说明了它的作用!通过 SoundTrack.addSound 函数,声音实例与 SoundTrack 相关联。任何属于给定 SoundTrack 的声音的总体音量可以通过 setVolume 函数来控制。当然,SoundTrack 中还有其他方法,但我们现在感兴趣的是这两个提到的函数。

回顾需求列表,第三个可以通过属性访问器提供,而第二个需求可以通过创建一个将友好的字符串标识符与对象映射之间的映射来满足(参见第五章添加场景和输入处理设计输入系统部分,了解更多关于此的示例)。最后一个需求可以通过使用 JS 标准的 Promise 对象的功能轻松满足。我们很快就会看到这些是如何一起工作的,但花点时间回顾一下之前讨论的详细需求,了解这是如何融入更大的图景中是值得的。

识别缺失的需求和解决与这些需求相关问题的潜在机会的一个有用方法是,在脑海中想象一个涉及当前问题的具体场景。在这种情况下,想象游戏画面。其中正在发生事情——玩家输入命令,应用程序响应以确认输入,并且在游戏中发生事件。同时,在玩家携带舱发射或坠毁时,背景中播放着机械嗡嗡声和尖叫声。在任何给定时间,都有大量的音频样本正在播放,但它们都有适合其类别或声音类型的不同音量。记住这个目标,因为当我们深入研究细节时,这个整体大图景将有助于引导并保持我们按正确的方向前进。

SpaceTruckerSoundManager

前两个步骤——设计和构建——关注我们如何使用 Babylon.js 的音频功能来创建我们的音频组件的具体细节,而第三个步骤关注我们如何使用该组件。以下代码片段的完整代码可以在 Space-Truckers GitHub 仓库的此章节分支中找到:github.com/jelster/space-truckers/blob/ch6/src/spaceTruckerSoundManager.js

设计

我们需要一些辅助逻辑来封装 Babylon.js 对象,并帮助我们管理它们的生命周期和行为。因为我们非常富有想象力,所以我们将它称为 SpaceTruckerSoundManager——听起来怎么样?可能有很多不同的构建方式,但我们希望 尽可能简单且可能有效的方法,那就是 spaceTruckerSoundMap.js 和它的 soundFileMap


const soundFileMap = {
    "title": { url: titleSongUrl, channel: 'music', 
      loop: true },
    "overworld": { url: backgroundMusicUrl, 
      channel: 'music', loop: true },
    "whoosh": { url: uiWhooshSoundUrl, 
      channel: 'ui', loop: false }
};

每个声音文件的 URL 由相关的 import 语句提供,对象键是一个任意(但唯一)的字符串名称。声音将被添加到的 SoundTrack 以及控制自动循环的 loop 标志是 soundFileMap 的另外两个数据点,因此让我们继续了解 SpaceTruckerSoundManager 如何使用它。

构建

每个 SpaceTruckerSoundManager 实例都初始化与相关场景,以及一个或多个 soundId 列表。这些存储在 registeredSounds 对象映射中,可以通过调用 sound(id) 访问器函数来检索给定的 Sound


registeredSounds = {};
sound(id) {
    return this.registeredSounds[id];
}

三种不同的 SoundTracks 存储在 channels 属性中,并在构造函数中初始化:


constructor(scene, ...soundIds) {
    this.channels.music = new SoundTrack(scene, 
      { mainTrack: false, volume: 0.89 });
    this.channels.sfx = new SoundTrack(scene, 
      { mainTrack: true, volume: 1 });
    this.channels.ui = new SoundTrack(scene, 
      { mainTrack: false, volume: 0.94 });

如前所述,constructor 接收 scene 和一个 soundIds 列表;之前未提及的是,构造函数完成后,组件尚未准备好使用——组件的 onReadyObservable 属性将在 SpaceTruckerSoundManager 完成加载和准备所有子 Sound 实例后通知订阅者:


Promise.all(onReadyPromises).then(readyIds =>
  this.onReadyObservable.notifyObservers(readyIds));

构造函数的大部分逻辑由对 soundIds 列表的循环组成。在循环内部是负责实例化和管理如何加载该 Sound 的逻辑,其状态由 prom 表示。当 SoundonLoaded 回调触发时,新加载的 Sound 将被添加到适当的通道 SoundTrack,并且承诺成功解决:


const onReadyPromises = [];
soundIds.forEach(soundId => {
    const mapped = soundFileMap[soundId];
    const chan = this.channels[soundId] ?? 
        scene.mainSoundTrack;
    // guard logic omitted for length
    const prom = new Promise((resolve, reject) => {
        const sound = new Sound(soundId, mapped.url, scene, 
           () => {
            chan.addSound(this.registeredSounds[soundId]);
            resolve(soundId);
        }, {
            autoplay: false,
            loop: mapped.loop,
            spatialSound: mapped.channel === 'sfx'
        });
        sound.onEndedObservable.add((endedSound, state) 
          => {
                this.onSoundPlaybackEnded
                    .notifyObservers(endedSound.name);
            });
        this.registeredSounds[soundId] = sound;
        });
        onReadyPromises.push(prom);
    });    
}

个体异步 Promises 以两种方式协调:首先,构建一个包含所有需要解决以继续的不同异步调用的承诺数组。其次,Promise.all 方法接受这个承诺数组,并返回另一个承诺,当解决时,将包含数组中每个承诺的结果。换句话说,它等待一切完成,然后宣布完成。

由于我们无法将构造函数标记为async,因此无法等待 Promise 的结果。相反,我们将一个函数附加到Promise.then链上,该链反过来通过onReadyObservable发出就绪信号。值得注意的是,没有任何错误或异常处理或捕获——这是我们希望在更健壮的生产应用中包含的内容!

集成

启动屏幕(见第五章添加场景和输入处理)已经播放了声音,因此我们希望用初始化在构造函数中的SpaceTruckerSoundManager实例来替换它:


this.audioManager = new SpaceTruckerSoundManager
  (scene, 'title');
this.audioManager.onReadyObservable.addOnce(_ =>
  this.onReadyObservable.notifyObservers());

场景将在audioManager.onReadyObservable触发之前就已经完成加载和初始化,因此我们将使用该事件来表示屏幕的整体就绪状态。为了使重构无缝且易于进行,SplashScene中的音乐字段被更改为get music()访问器,该访问器从底层的audioManager检索标题声音:


get music() {
    return this.audioManager.sound("title");
}

因此,不需要对其他代码进行任何更改,就可以将SpaceTruckerSoundManager集成到SplashScreen中——它已经准备好了!这标志着我们的小插曲结束,但这不会是我们最后一次看到它,因为我们将在本章的后面直接使用它。然而,现在我们将转换话题,来探讨路线模拟及其构建方式。

设计路线模拟的游戏元素

Helios 星系是《太空卡车司机》的背景设定,但到目前为止,我们还没有深入探讨这其中的含义。在许多虚构和非虚构书籍中都有详尽的描述,那就是太空是巨大的。真的非常大。在太阳系的尺度上,涉及的距离与事物的相对大小相比是如此之大,试图在我们的游戏中准确表示这个巨大的尺度既不有趣也不高效。

Helios 系统概述

下面的图是一个从鸟瞰视角对 Helios 系统——太空卡车司机的家园——的相当风格化的视图。括号内的行星体显示了两种不同的起始和结束路线可能性——向外延伸到太阳,反之亦然。在下面的图中,不同的阴影区域对应于玩家在驾驶阶段可能遇到的不同潜在情况:

图 6.1 – 路线规划游戏地图的描述

图 6.1 – 路线规划游戏地图的描述

最接近的行星是赫尔墨斯,之所以这样称呼,是因为它在赫尔墨斯的紧密轨道上快速移动。在游戏世界中,它是路线规划的主要起始位置。从赫尔墨斯出发的路线将结束于宙斯繁忙的建筑工地。另一种选择,B路线,从围绕泰勒斯行星的恒星稍远的地方开始,其目的地是遥远的冰工厂伊纳斯。

在任意一组起点和终点之间,都存在着整个太阳系那么多的潜在危险和障碍。靠近动荡的恒星,太阳风暴是常见的。它们可以通过扭曲和卷曲太空道路,迫使操作员驾驶他们的车辆和货物穿过它们以保持准确的导航,从而毁掉太空货车的日子。在蓝色和绿色的宝石泰勒斯轨道之后,又出现了一个导航危险,即一个密集的陨石带。

在现实世界中,没有足够厚的陨石带可以成为有意义的导航危险,但在太空货车的世界中,几十年的陨石开采作业已经散布和释放了足够的碎片,使得它成为穿越岩石障碍物船只的真正问题。在陨石带之后是气态巨行星宙斯,它是赫利俄斯系统中行星的巨人,那里工业活动如蜂群般闪烁和闪耀,日夜不息。繁忙的工厂需要持续的原料、备件和供应,这就是太空货车的作用所在。然而,直接到达宙斯并不像看起来那么容易。

满足于将他们的工业仅仅局限于巨大行星的轨道,最近在领先和落后的拉格朗日点进行的工程项目利用了所谓的特洛伊希腊家族的陨石中丰富的资源。任何熟悉道路建设的人都知道,道路施工中的延误、绕行和偶尔的交通指挥员指挥被转移的交通,那些施工区域在太空中也没有什么不同!

在宙斯系统的发光熔炉和工厂之后,一切开始变得黑暗和寒冷。冰冻巨行星詹纳斯位于内系统温暖嗡嗡的活动和外系统宁静黑暗之间的入口处。太空货车在这个区域出发和到达,进行运送在阳光明媚的地方收集的储存能量的旅程,带着装满维持内系统生命至关重要的冰冻挥发物的满载货物离开。然而,他们并不孤单——大量简单的太空生命在这片寒冷而遥远的平原上漫游。他们不习惯看到访客,这对那些在长途跋涉末尾的粗心大意的太空货车驾驶员来说是一个导航危险。

将所有内容整合在一起,以下截图展示了路线规划开始时系统的样子:

图 6.2 – 路线规划屏幕的 ReadyToLaunch 阶段。这显示了大多数参与者,包括恒星、目的地网格、行星、发射箭头和货物

图 6.2 – 路线规划屏幕的 ReadyToLaunch 阶段。这显示了大多数参与者,包括恒星、目的地网格、行星、发射箭头和货物

现在我们已经从宏观的角度审视了整个系统,是时候分析个别演员并寻找它们之间的共同点了。这使我们能够开始创建游戏组件,这些组件将有助于服务游戏概念,这在某种程度上就像列出工作要求清单。

演员及其行为

在我们深入探讨构成路线规划屏幕的不同对象和组件的具体细节之前,让我们先看看我们的游戏对象在对象层次结构中的样子。基本思路是,我们知道我们的游戏对象需要一些数据和行为,但同时我们希望避免编写重复的代码。我们需要能够更新或推进模拟,有时需要非常精细的粒度,因此我们通常会避免让组件注册它们自己的onBeforeRender处理程序,而是提供一个update(deltaTime)方法来达到相同的目的。以下是描述我们各种组件如何相互交互、如何与数据以及与应用程序交互的一种方式:

Figure 6.3 – 路线规划屏幕中涉及的游戏组件的类图

Figure 6.03_B17266.jpg

图 6.3 – 路线规划屏幕中涉及的游戏组件的类图

在前面的图中,类的抽象层次结构位于中心。BaseGameObject是最少派生的(例如,它不扩展任何其他类型),而游戏概念的各个类是最派生的。RoutePlanningScreen托管这些游戏组件类的各种实例,并以类似于整体渲染管道的方式管理它们的行为:

Figure 6.4 – SpaceTruckerApplication 的更新和渲染周期,简化版

Figure 6.04_B17266.jpg

图 6.4 – SpaceTruckerApplication 的更新和渲染周期,简化版

每当SpaceTruckerApplication调用RoutePlanningScreen.update方法时,RoutePlanningScreen都会遍历其自己的子组件,并(可选地)调用它们的更新方法。当所有这些完成并且RoutePlanningScreen完成其更新周期后,屏幕最终被渲染。这个图中缺少了一些步骤,比如物理步骤的前后,但这就是我们的游戏基础状态如何改变和发展的机制。这就是我们描述游戏对象所需的最基本行为的方式,所以让我们利用这些知识来编写代码吧!

抽象BaseGameObject

BaseGameObject类(其源代码见github.com/jelster/space-truckers/blob/ch6/src/baseGameObject.js)提供了我们不想在游戏对象之间重复的低级共享功能。它是任何我们可能希望在Scene中渲染的对象的最小公倍数。BaseGameObject的许多属性都是简单的代理,允许访问构成游戏对象的 Babylon.js 组件的底层属性,例如Vector3旋转属性访问器:


get rotation() { return this.mesh?.rotation; }
set rotation(value) { this.mesh.rotation = value; }

除了整合对各种组件和数据属性的访问之外,BaseGameObject还提供了两个关键行为:updatedispose

在这个基类中,update方法似乎没有做太多,因为它只是更新了lastSceneTime属性,但它的作用很重要;许多类型的行为需要跟踪自上一帧渲染以来经过的时间以及前一个值,以便正确地集成诸如速度和加速度之类的因素。

重要提示

如果一个扩展类依赖于deltaTime和/或lastSceneTime值,确保在其update方法中首先调用super.update(deltaTime)

对于有传统面向对象编程(OOP)语言经验的开发者来说,这种用法可能很熟悉:一个抽象基类为其更派生的类提供公共功能。一个例子是我们实现的轨道力学模拟。

定义轨道力学模拟元素

Planet类(将在稍后介绍)基于OrbitingGameObject类(github.com/jelster/space-truckers/blob/ch6/src/orbitingGameObject.js),而OrbitingGameObject又派生自BaseGameObject原型。OrbitingGameObject提供了一套关于涉及轨道运动和重力加速度的各种计算的基本数据和行为——那些我们否则会在代码库的多个地方重复的、既复杂又有趣的物理和数学内容。尽管它并不打算直接渲染此类对象,但通过适当地设置meshmaterial属性,仍然可以这样做。以下表格总结了OrbitingGameObject的数据和行为:

![Figure 6.5 – OrbitingGameObject 组件的行为和数据摘要

![Figure 6.05_B17266.jpg]

图 6.5 – OrbitingGameObject 组件的行为和数据摘要

通过抽象掉轨道和重力计算的具体细节,更派生的类更容易理解、构建和维护。一个很好的例子是Planet类。

实现星体和行星

Planet类的构造函数逻辑的主体(github.com/jelster/space-truckers/blob/ch6/src/route-planning/planet.js)主要致力于读取输入的planData,然后实例化和配置组件的渲染特定方面——例如创建材质、加载和应用纹理等任务。请注意,在Planet类文件中没有提到与我们的轨道模拟相关的内容——只有使特定的Planet实例与其他实例不同的具体细节。

为了帮助这项工作,该类是数据驱动的:传递给构造函数的planData包含了所需的所有数据。这就是我们一直在应用的混合继承/组合模式的美妙之处;我们的每个组件只需要关注它被设计用来完成的具体任务,而无需关心其他任何事情!因此,典型的planData看起来是这样的:


{
        name: "tellus",
        posRadians: Scalar.RandomRange(0, 2 * Math.PI),
        posRadius: 750,
        scale: 30,
        color: new Color3(0.91, 0.89, 0.72),
        diffuseTexture: earthDiffuseUrl,
        normalTexture: earthNormalUrl,
        specularTexture: earthSpecularUrl,
        lightMapUrl: earthCloudsUrl,
        mass: 1e14
    } 

也许这看起来很熟悉?在第二章中,我们看到了一个用于生成加载屏幕中环绕行星的非常相似的结构——只是增加了一些新的成员(例如质量)。同样,Star类(https://github.com/jelster/space-truckers/blob/ch6/src/route-planning/star.js)可以非常简洁,尽管它不像其他游戏对象那样环绕,但它确实参与了引力计算。

通过设置autoUpdatePosition = false,星星将不会在世界的中心位置移动。这使得构造函数和随后的类相当简单:


constructor(scene, options) {
    super(scene, options);
    this.autoUpdatePosition = false;
    const starData = options;

    this.mesh = MeshBuilder.CreateSphere("star", 
      { diameter: starData.scale }, this.scene);
    this.material = new StandardMaterial("starMat",
      this.scene);
    this.material.emissiveTexture = new
      Texture(starData.diffuseTexture, this.scene);
}

我们剧中的最后两个角色是玩家的化身,也称为货物,以及构成小行星带的危险岩石集合。我们将在后面介绍货物,因为在我们介绍小行星带之前,我们首先需要介绍一个重要的新概念——瘦实例。如果你对数字和数学有恐惧(如果你有,那也没关系!),提前警告你——前方有矩阵和四元数,但无需担心——你不需要解决任何复杂的数学问题。所有艰苦的工作和深入思考都是由 Babylon.js 中的函数完成的,所以我们只需要了解何时以及如何使用它们!

程序化生成小行星带

在我们讨论AsteroidBelt类(github.com/jelster/space-truckers/blob/ch6/src/route-planning/asteroidBelt.js)的细节之前,我们应该回顾一下在渲染网格的上下文中的一些定义和概念。首先,重要的是要理解在最简单的层面上,什么是网格。从最简单的解释开始,网格是在 3D 空间中按特定顺序排列的一组点。更深入地说,网格是在 3D 空间中可以一起定位、旋转和缩放的点的集合。用极其精确的术语来说,网格是一组向量数组,作为一组矩阵的组合,分别表示 3D 模型各部分的定位、平移和旋转。虽然网格的几何形状一次发送到 GPU,但相同的几何形状可以被 GPU 链接(重用)以渲染所需的所有额外实例。

在常规实例的情况下,重要的是要理解,尽管网格几何形状没有在 GPU 中复制,但由于需要每帧迭代每个实例以进行处理,因此仍然存在 CPU(JavaScript)开销。这是能够保留对单个实例控制权的代价,但某些情况可能不需要那么多的控制。这就是薄实例发挥作用的地方。

注意

如果你使用过 3D 建模工具,如 Blender,Babylon.js 中的薄实例被称为 Blender 中的链接对象。

我们在这里关注的本质概念是,在某些情况下,我们可能需要数百、数千甚至数万个给定网格的独立副本来渲染到特定的场景中,但我们不希望承担处理和维护该网格几何形状多个副本所需的内存或 CPU 开销。想想森林中的树木(例如,playground.babylonjs.com/#YB006J#75),或者由乐高®组成的海洋(例如,playground.babylonjs.com/#TWQZAU#3),或者,就我们案例而言,大量的小行星——巨大的漂浮太空岩石(例如,playground.babylonjs.com/#5BS9JG#59),向 Babylon.js 社区成员Evgeni_Popov致敬)。

在考虑(薄)实例时,需要记住的关键限制如下:

  • 所有实例,无论是薄实例还是其他实例,都必须共享相同的材质。

  • 尽管比克隆网格更高效,但实例仍然受 CPU 和 GPU 的限制。

  • 对于薄实例,你必须通过手动操作特定实例的矩阵值来操纵单个实例的属性(例如,位置、缩放、旋转等)。

  • 所有细实例总是被绘制(或不是)——没有方法可以隐藏或跳过单个细实例的渲染

  • 纤细实例将碰撞检查作为一个单独的、巨大的网格;无法为单个实例注册碰撞检测

即使考虑到这些限制,使用细实例渲染小行星带也是有意义的——我们至少需要一千(或更多...)个这样的实例,所以我们不需要对它们进行太多控制,而且由于我们希望它们看起来相对均匀,它们共享相同的材质是可以的。我们将在稍后更深入地探讨我们将用于小行星的材质,所以现在让我们看看我们是如何通过类比的力量创建每个小行星的细实例的。

重要提示

我们假设实例的缩放、位置和旋转分量具有相同的符号(这在矩阵术语中被称为具有相同的符号行列式)。你不应该直接混合符号相反的元素。例如,以下语句会导致混合行列式符号:

Matrix.Compose(new Vector3(-1, 1, 1),Quaternion.Identity(), newVector3(2, 1, 0))

这是因为第一个参数中的负号与表示正1Identity四元数相冲突。

研究黑洞的天体物理学家有一种有趣的方式来描述他们科学研究的特性。

想法是,任何给定的黑洞只有三个可观察的特性——电荷、质量和唯一定义它的脊柱,而像人、恒星和植物这样的东西,有相当多的附加属性使它们——以及你——独一无二。就像这个Float32Array用作矩阵缓冲区。大小应该是要创建的小行星数量的九倍,以存储结果数据。

对于我们想要创建的每一个小行星,我们必须执行以下操作:

  1. 生成一组三个向量,分别对应位置、旋转和缩放。

  2. 随机设置每个向量的分量值为一个在限制范围内的数字。

  3. 将新向量添加到相应的数组中。

  4. 在数组中创建并添加一个新的、空的四元数和矩阵。

  5. 将旋转向量转换为四元数。

  6. 使用向量和四元数来组合矩阵。

  7. 将矩阵元素复制到矩阵缓冲区。

  8. thinInstance缓冲区设置在目标网格上,使其与矩阵缓冲区中的实例相匹配。

当我们随机生成值时,我们需要确保这些值都在有效参数范围内,并且我们可以通过几种不同的方式来完成。第一种用于缩放和旋转向量值,有助于创建从光滑的IcoSphere网格开始的粗糙、岩石状表面。因为Math.random()返回一个介于零和一之间的浮点数,所以我们通过一个表示我们想要生成的值范围中的最大值的因子来扩展这个数字——换句话说,当随机值等于一时。

由于也可能得到零值,比例有一个额外的加性常数以确保至少有一个最小值。一个类似但更简单的表达式为每个轴生成旋转。Vector3 缩放适用于定义小行星实例的缩放和旋转,但指定位置需要另一种方法。

再次强调,我们必须从线性思维转变为角度思维。使用 Babylon.js 的 Scalar.RandomRange() 工具函数,我们可以通过定义 innerBeltRadiusouterBeltRadius(即,我们生成一个随机数 rTheta,然后与另一个介于 0 和 2 * π 之间的随机数组合)来在环面(甜甜圈形状)的某个位置生成一个随机点。

注意

回想一下,正弦和余弦函数的输入是以弧度为单位的,一个完整的圆可以用 2 * π 或大约 6.28319 弧度来描述。

小行星在世界的位置中 XZ 轴的值是通过将径向(角度)值转换为世界坐标来计算的——例如,Math.sin(theta)Math.cos(theta)——这会产生一个归一化值,然后乘以我们的随机比例常数,以正确放置对象在世界中。因为我们使用一个非常简化的数学模型来在空间中分布小行星,所以我们可以通过将随机数的半数乘以密度配置常数来处理垂直的 Y 轴:


this.positions.push(new Vector3(
    Math.sin(theta) * rTheta,
    (Math.random() - 0.5) * density,
    Math.cos(theta) * rTheta
));

更新小行星的旋转、位置或比例是一个两步的过程。第一步是让 AsteroidBelt 类修改对应于所需小行星实例的索引处的所需数组中的值。在更新循环中,每个小行星的旋转值通过修改 this.rotations[i] 来进行微调。

完成这一步后,第二步与原始生成算法相同,方便地分解为类中的 updateMatrices 函数。创建和更新薄实例数据之间的唯一区别在于,当我们更新时,我们使用 mesh.thinInstanceBufferUpdated 而不是 mesh.thinInstanceSetBuffer

注意

关于使用 Babylon.js 与网格、实例和 GPU 的技术细节,请参阅官方文档doc.babylonjs.com/divingDeeper/mesh/copies/instancesdoc.babylonjs.com/divingDeeper/mesh/copies/thinInstances

现在终于到了将我们一直在看的所有内容整合在一起的时候,开始检查实际的路线规划屏幕。尽管由于缺乏明显的整体关注点可能显得有些混乱,但我们还没有建立适当的环境来捕捉那个画面。尽管如此,跟随这种概述可能仍然很困难,所以这里又是另一个 PG(Playground)派上用场的时候。如前所述,这个片段(playground.babylonjs.com/#5BS9JG#59)是行星模拟的初步、基本实现,尽管它与游戏代码不完全相同,但它展示了之前描述的所有概念,以及我们尚未涉及的一些概念!

添加CargoUnit

CargoUnit类是游戏将玩家投射到游戏世界中的这一部分。它继承自OrbitingGameObject,但它不会自动更新其位置——就像我们刚刚查看的Star类一样。与Star类不同,这里发生了一些额外的事情。

从数据开始,CargoUnit类跟踪一些游戏特定的飞行信息,例如timeInTransitdistanceTraveledisInFlight布尔标志与PLANNING_STATE.InFlight隐式相关联,如果之前不明显的话。这些和其他数据由RoutePlanningScreenPlanningScreenGui(稍后详细介绍)消耗,并作为希望现在熟悉的更新方法模式的一部分进行更新。

在更新过程中,有一些逻辑用于将货物的旋转指向飞行方向,这涉及到一点向量数学,但更重要的是,有一些逻辑将当前帧累积的重力作用到箱子上。由于力是按每秒的效果来计算的,因此必须使用deltaTime将其缩放到自上一帧以来经过的时间量。在应用力之后,我们清除currentGravity字段,以防止力渗透到渲染帧之间:


update(deltaTime) {
        super.update(deltaTime);
        if (this.isInFlight) {
            this.lastGravity = this.currentGravity.clone();
            const linVel =
              this.physicsImpostor.getLinearVelocity();
            this.lastVelocity = linVel.clone();
            linVel.normalize();
            this.timeInTransit += deltaTime;
            this.distanceTraveled +=
              this.lastVelocity.length() * deltaTime;    

            this.rotation = Vector3.Cross(this.mesh.up,
              linVel);
            this.physicsImpostor.applyImpulse(this.
              currentGravity.scale(deltaTime),
              this.mesh.getAbsolutePosition());
            this.currentGravity = Vector3.Zero();
        }
    }

向量叉积是一种数学运算,它接受两个正交向量(即,两个相互垂直的向量)并产生一个新的向量,该向量指向与两个输入都垂直的方向。通过输入cargoUnit的(归一化)物理速度以及局部向上轴,我们得到了cargoUnit必须采用的旋转坐标,以便将其指向行进方向。

注意

对像cargoUnit这样的非对称质量体施加的力将导致角旋转,或扭矩,使单位围绕其质心疯狂旋转。这并不像游戏崩溃那样糟糕,但也不算好,尤其是当与TrailMesh结合使用时!通过将旋转指向行进方向,我们确保重力作用力传递到单位的线性速度,而不是角速度。此外,我们还防止TrailMesh扭曲成结——这是生成下一阶段路线时的一个关键因素。

在转移焦点之前,定义 CargoUnit 的行为是我们需要覆盖的最后一件事。除了更新行为外,该类还实现了三个其他动作。

resetlaunchdestroy动作从其名称中就可以很好地解释。reset方法在模拟重新启动时被调用,例如当玩家按下键盘上的Delete键时。它在将自身移动回初始起始位置并设置isInFlight标志为false之前,会清除CargoUnit中存储的所有状态数据。launch函数是实例化TrailMesh的地方,以及发射器初始的推动;它负责适当地设置isInFlight标志。最后,当SpaceTruckerApplication确定CargoUnit已被正式销毁时,会调用destroyed函数,例如,当遇到一个不喜欢撞击的障碍物时。它负责确保CargoUnit在碰撞后不会以无限速度飞走,而是保持在原地。

在这么短的时间内,确实需要覆盖大量的不同概念和类,但还有更多内容可以探索,我们不可能在这个话题上停留更长时间。我们多次提到,我们最终会详细介绍如何使用物理引擎实现飞行机制,我们几乎已经到达了可以创建关键知识量的点。这种知识将推动我们向更深入的理解和进步迈进——请耐心等待!

建立基本路线规划屏幕

在 Space-Truckers 中,我们至今为止所做的工作中,SpaceTruckerPlanningScreen(github.com/jelster/space-truckers/blob/ch6/src/route-planning/spaceTruckerPlanningScreen.js)无疑是其中最复杂的。我们已经准备好通过首先查看单个组件来管理这种复杂性;由于需要尝试和跟踪的事情更少,我们更容易专注于手头的话题。让我们分解屏幕的不同方面,使其更容易管理。我们将关注三个基本类别或方面 – 到现在为止,这应该开始变得熟悉了 – 数据、行为和状态转换。每个都有其独特的角色,通过依次理解每个方面,我们将为创建模拟的下一步做好准备。

数据的开发

运行模拟和体现游戏机制需要大量的不同数据。其中一些,如launchForceorigincargo,处理游戏机制,而其他一些,如planets数组、asteroidBeltstar对象,存储用于引力模拟所需的信息。onStateChangeObservable被其他组件(例如,位于github.com/jelster/space-truckers/blob/ch6/src/route-planning/route-plan-gui.jsPlanningScreenGui类)用于响应gameState属性的变化,这是一个PLANNING_STATE键之一的枚举值:


static PLANNING_STATE = Object.freeze({
        Created: 0,
        Initialized: 1,
        ReadyToLaunch: 2,
        InFlight: 3,
        CargoArrived: 4,
        GeneratingCourse: 6,
        CargoDestroyed: 7,
        Paused: 8
    });

为此屏幕定义的数据补充是preFlightActionList(见第五章添加场景和输入处理),它指定了这个类应该处理哪些输入动作的名称,以及输入是否应该被反弹,或防止在短时间内重复:


const preFlightActionList = [
    { action: 'ACTIVATE', shouldBounce: () => true },
    { action: 'MOVE_OUT', shouldBounce: () => false },
    { action: 'MOVE_IN', shouldBounce: () => false },
    { action: 'GO_BACK', shouldBounce: () => true },
    { action: 'MOVE_LEFT', shouldBounce: () => false },
    { action: 'MOVE_RIGHT', shouldBounce: () => false },
]; 

在这个特定实例中,我们的行为将与之前提到的因素如launchForce相联系,允许玩家使用已配置的任何输入方法来选择他们的发射方向、时机和速度 – 除了触摸和视觉控制(这些必须在 GUI 中创建和托管)。

如您所预期,构造函数是大多数屏幕对象初始化的地方。游戏组件,如soundManageractionProcessorcameralightsskybox等,都是在这里创建和配置的。对于照明,我们使用一个强度调至一千万的PointLight – 空间的广阔是黑暗的 – 我们想要确保star的光线能以我们想要的方式照亮场景。这涵盖了构造函数中发生的许多熟悉事件,但还有更多超出我们熟悉范围的事情在进行中。

数据驱动的驾驶行为

驱动代码设计的一个重要因素是需要尽可能通过数据来驱动模拟的行为(而不过度)。这意味着我们不是直接将值硬编码到SpaceTruckerPlanningScreen中,而是定义gameData文件来保存我们的配置值。通过阅读传递给构造函数的配置数据,可以轻松使用任意、易于更改的值来运行模拟(关于重构以适应迭代的更多内容将在稍后讨论)。例如,起源星球和目的地星球等信息存储在gameData中,以及关于系统的物理信息(例如,PrimaryReferenceMass,即中心星体的重量)。

SpaceTruckerPlanningScreen的一些组件是在类内部定义的。一个例子是launchArrow网格,它是通过结合arrowLines Vector3数组和MeshBuilder.CreateDashedLines函数创建的,该函数从传入的点数组返回一个网格。其他网格则简单得多,例如destinationMesh——一个与Planet关联的球体,用于视觉和碰撞目的。

准备实现游戏机制是我们任务的一部分,因此我们将创建并设置带有ActionManagerdestinationMesh,该ActionManager将监视与cargo.mesh(玩家的货物单位)的交点,如果发生这种情况,将调用cargoArrived函数:


this.destinationMesh.actionManager = new ActionManager(this.scene);
this.destinationMesh.actionManager.registerAction(
   new ExecuteCodeAction(
       {
           trigger:
             ActionManager.OnIntersectionEnterTrigger,
           parameter: this.cargo.mesh
       },
       (ev) => {
           console.log('mesh intersection triggered!', ev);
           this.cargoArrived();
       }
   ));

cargoArrived的目的是为Screen设置当前状态,以及任何其他需要的与状态改变相关的动作来停止模拟。目前这已经足够,但稍后我们将向此函数添加额外的行为。

SpaceTruckerApplication在其每帧的update方法中采取不同的动作集合类似(参见第三章建立开发工作流程),通过切换currentState来控制其行为,SpaceTruckerPlanningScreen也是如此。首先要做的是计算自上次渲染帧以来经过的毫秒数,这可以通过使用deltaTime参数(用于测试)或通过scene.getEngine().getDeltaTime()获取(如果缺失)。之后,actionProcessor更新其输入列表和动作映射。现在,是时候switchgameState了:


switch (this.gameState) {
    case SpaceTruckerPlanningScreen.PLANNING_STATE.Created:
        break;
    case SpaceTruckerPlanningScreen.
        PLANNING_STATE.ReadyToLaunch:
        this.star.update(dT);
        this.planets.forEach(p => p.update(dT));
        this.asteroidBelt.update(dT);
        this.cargo.update(dT);
        this.cargo.position = this.origin.position.clone().
          scaleInPlace(1.1, 1, 1);
        break;
    case SpaceTruckerPlanningScreen.
        PLANNING_STATE.InFlight:
        this.star.update(dT);
        this.planets.forEach(p => p.update(dT));
        this.asteroidBelt.update(dT);
        this.cargo.update(dT);
        let grav =
          this.updateGravitationalForcesForBox(dT);
        this.cargo.physicsImpostor.applyImpulse(grav,
          this.cargo.mesh.getAbsolutePosition());
        break;
    // ...and so on
}

从这个语句中可以看出,当gameState处于ReadyToLaunchInFlight阶段时,各种天体会调用它们的update方法。换句话说,只有当游戏状态是ReadyToLaunchInFlight时,模拟才会前进。这引发了一个整体问题:我们将如何实现此屏幕的标志性功能:轨道力学模拟

状态转换

如前所述关于cargoArrived函数的讨论,屏幕的gameState变化是由cargoArrived和类似函数触发的。以下是不同状态变化、引发变化的函数及其用法的总结:

img/Table_6.01_B17266.jpg

除了setReadyToLaunchState函数外,这个屏幕中的所有状态变化都源于游戏中的事件发生或直接用户输入。setReadyToLaunchState是例外的原因在于,虽然屏幕作为整体应用程序初始化过程的一部分被创建,但某些事情只有在场景正在渲染时才能发生。此外,我们需要能够任意地将屏幕重置到其初始状态,这样玩家在想要尝试新路线时不必重新启动整个应用程序。以下是一个使用CargoArrived状态的基本成功路线的示例:

Figure 6.6 – The Route planning screen after a successful cargo arrival at the Destination planet. The trail mesh shows the path of the cargo from start to finish

图 6.6 – 在目标行星成功到达后的路线规划屏幕。轨迹网格显示了货物从起点到终点的路径

在讨论路线的话题上,一开始可能很难弄清楚如何获得成功的货物发射,所以这里有一个快速提示——朝向与轨道运动相反(逆行)的方向瞄准,以获得更直接的飞行路径。如前一张截图所示,你可以看到货物单元的轨迹相对于相机呈逆时针方向,而行星则呈顺时针方向轨道运行。

理解我们考察的路线规划屏幕的三个方面有助于建立输入与应用程序应如何行为(其输出)之间的联系。行为被定义为依赖于数据来驱动该行为的特定细节。游戏数据指定了行星可能与其恒星轨道的距离、它们的质量等等,但应用程序状态是最终控制和决定它们在星舞中移动与否及其移动多少的因素。

创建轨道力学模拟

当思考SpaceTruckerPlanningScreen中涉及的各种组件时,考虑模拟的运行方式很重要。每一帧(实际上,它可能每帧超过一次,但为了简单起见,我们将采用每帧一次),物理模拟更新其内部状态。这个状态对我们来说大部分是透明的——尽管如果需要我们总能访问它——但它通过在对象的位置和/或旋转上所做的物理步骤变化而显现。为了使我们的CargoUnit执行必要的引力摇摆,我们需要告诉物理模拟它应该施加的力,这个力是从系统累积的引力中计算出来的。

虽然外观非常相似,但InFlight游戏状态与ReadyToLaunch有两个主要区别:当我们处于InFlight状态时,我们希望货物受到系统中所有不同质量天体的重力影响。为了保持整洁,我们将汇总所有这些力的任务封装到updateGravitationalForcesForBox函数中:


updateGravitationalForcesForBox(timeStep) {
    const cargoPosition = this.cargo.position;
    let summedForces =
      this.star.calculateGravitationalForce(cargoPosition);
    this.planets.forEach(p => summedForces.addInPlace(p.
      calculateGravitationalForce(cargoPosition)));
    return summedForces.scaleInPlace(timeStep);
}

这个函数的优点在于,它可以利用OrbitingGameObject提供的基功能来获取每个组件对货物单元所承受的总体力的贡献,尽管我们正在混合不同类型的对象,如星星行星。返回的Vector3被传递给physicsImpostor(见理解物理部分)作为施加在货物对象上的冲量推力。从那里,我们让物理引擎接管更新货物单元位置和速度的任务。

理解物理

大多数人都熟悉这样一个寓言故事:艾萨克·牛顿在头上被一个掉落的苹果砸中后想出了他的万有引力理论,以及他是如何改变我们思考我们所居住的世界和我们所居住的宇宙的方式。我们不需要记住这些方程就能体验到重力的效果——作为一种自然法则,它并不在乎人们对它的看法如何。星星在夜空中旋转闪烁,行星在宇宙的舞蹈中旋转,所有这一切——至少从 17 世纪科学家的角度来看——都可以用几个方程来描述。

重要提示

我们将在这里稍微深入一些物理和代数,但与更现实的模拟相比,这将大大简化。例如,通过假设我们的行星都具有完美的圆形轨道,我们消除了实现椭圆轨道所需更复杂方程的需要。我们简化这一点的另一个例子是,力计算只在对cargoUnit进行,而不是在每一个质量天体之间进行,正如现实世界中的情况一样。

第一条也是最基本的是牛顿第一定律。它描述了物体、施加在物体上的力以及物体对加速的抵抗——它的惯性之间的关系:

图 6.7 – 牛顿第一定律。作用在物体上的力(一个矢量)等于物体的质量乘以其当前的加速度。这通常被重新排列以求解 m 或 a 未知数

图 6.7 – 牛顿第一定律。作用在物体上的力(一个矢量)等于物体的质量乘以其当前的加速度。这通常被重新排列以求解 m 或 a 未知数

由于当我们运行模拟时,我们最终想要计算的是力,我们可以用以下方程替换前面方程的左边。两个质量值相互抵消,得出一个相当奇怪的结论——对我们计算来说,只有大体的质量才是重要的。cargoUnit 的质量根本不考虑:

图 6.8 – 牛顿万有引力定律。在 OrbitingGameObject 中实现。引力常数 (G) 的值已经通过实验验证到许多小数位

图 6.8 – 牛顿万有引力定律。在 OrbitingGameObject 中实现。引力常数 (G) 的值已经通过实验验证到许多小数位

用日常语言来说,这个方程可以这样表述:一个给定质量 (m1) 的物体在距离另一个质量 (m2) 的 r 处所受的力 (F) 等于一个常数 (G),乘以两个质量的乘积除以它们之间距离的平方。在计算术语中,我们分别计算力的方向(通过两个物体位置的向量减法)和其大小(通过前面的方程),或称为比例,然后再组合并返回一个最终结果向量。

单位的选择可以是任意的,但必须保持一致;本文假设使用公制系统,因为理智是一种珍贵的财产,应该珍惜。因此,质量以千克为单位,半径以米为单位。这使得得到的力具有单位 。换句话说,这是衡量 1 千克质量在 1 秒内由施加的力加速多快的度量,被称为 N,即牛顿。更不明显的是方程的一些含义。

首先,力,F,是一个矢量值,不是一个标量。这意味着力既有方向分量也有大小分量。

第二,与具有正负 电荷 的电力和磁力不同,重力始终是正的。因为数学家们一直在试图证明他们的理论,他们有一种幽默感,这个事实在方程中用负号表示,表明物体总是被引力质量吸引,而不是被推开。

第三,物体所受的力是由所有可能影响该物体的质量产生的力的总和所决定的。这意味着从被审查物体的对立位置产生的相等或更强的结果力可能会减少整体力,甚至完全抵消。

最后关于这个话题的补充说明,针对那些可能对微积分和数值积分有一定了解的人:尽管我们物理计算之间的时间步长可能大约是 1/60 秒,但通过求和的直接积分方法本质上是不准确的。然而,这已经足够准确,允许模拟展现出我们使用简化的轨道物理模型所期望看到的涌现行为。单个物体对的引力计算完整实现包含在 OrbitingGameObject.calculateGravitationalForce(position) 中。代码也可以在 github.com/jelster/space-truckers/blob/8a8022b4cac08f1df9e4c7cfc8ff7c6275c71558/src/orbitingGameObject.js#L72 查看。

希望这次对抽象方程的简短探讨并没有太过令人畏惧,因为这就是最糟糕的部分(目前如此...),理解这些方程的结构有助于阐明模拟的 InFlight 行为。但在模拟可以进行任何 InFlight 计算之前,必须初始化和配置物理引擎及其依赖的数据。

推动模拟物理的引擎

Babylon.js 的发行版包含对四个独立物理引擎的原生支持:CannonOimoEnergyAmmo。每个引擎都有其优缺点,尽管并不完美,但 Space-Truckers 中使用的是 Ammo 物理库。选择取决于个人项目的需求以及开发者的偏好,但有一些与开发者经验相关的实际问题值得理解。

Babylon.js 背后的团队致力于维护对用户的向后兼容性支持。正如我们在 第三章 中讨论的,建立开发工作流程,Babylon.js ES6 库保留了当时使用的先前版本的一些模式,例如仅具有副作用 import 语句的使用。更复杂的是,Babylon.js 团队并不拥有或维护任何物理引擎本身——只有库的 Babylon.js 插件包装器——但 CDN 和 完整 的 Babylon.js 发行版都捆绑了所有支持的引擎。

由于使用 ES6 模块进行摇树优化(tree-shaking)的目的是仅打包和加载所需的源文件,因此有必要在 package.json 中添加一个或多个物理引擎的引用。不幸的是,目前没有任何带有可用 Babylon.js 插件的库在 NPM 上发布受信任、验证和更新的包,但 Ammo 的 GitHub 仓库在过去几年中显示出最一致的活动,表明它很可能继续在更新、错误修复和功能增强上保持活跃开发,这正是 Node 直接从 GitHub 仓库引用包的支持非常方便的地方。

重要提示

Ammo.js 物理库的初始化等待 Ammo 的承诺。为确保库已正确初始化和加载,需要一个包装器。/src/externals/ammoWrapper.js 模块首先导入 ammo.js 库,并导出两个变量:ammoModule 本身以及一个在解决之前填充 ammoModuleammoReadyPromise

SpaceTruckerPlanningScreen 中,ammoReadyPromise 作为构造逻辑的一部分被导入和解决,确保在调用 initializePhysics 时,AmmoJsPlugin 已经拥有了完成其工作所需的一切(有关 initializePhysics 的更多信息,请参阅下一节)。

使用已经构建和证明的物理引擎的好处是,除了设置物理模拟的所需参数外,没有太多的事情要做。这是在 SpaceTruckerPlanningScreen.initializePhysics 方法中完成的。

配置飞行中的物理模拟

initializePhysics 方法在对象构造期间不会被调用,因为我们知道屏幕最初不会向玩家显示,我们想要确保在用物理引擎做任何事情之前,场景已经完全设置好,包括所有涉及的网格。它是由 setReadyToLaunchState 调用的,由于该方法可以通过几种不同的方式调用,initializePhysics 函数不能对引擎的当前状态做出假设。这就是为什么在每次飞行之前都会重置和清除物理引擎的原因 – 保持引擎和游戏之间的接口不透明,使得代码更简单。

我们首先想要确保并做的是将 scene.gravity 设置为 Vector3.Zero – 否则,它将默认为地球正常的值(0, -9.8, 0)。这是一个太空模拟,如果玩家以错误的速度和方向下落,那就无法通过审查!接下来,我们必须在完全禁用引擎之前销毁任何现有的物理模拟器。这为将新创建的 AmmoJSPlugin 传递给 scene.enablePhysics 方法铺平了道路。让我们慢下来一会儿 – 什么是 PhysicsImpostor

大多数网格(或者至少大多数有趣的网格)将具有复杂的几何形状。网格的整体形状可能在所有轴上不对称,并且可能会有凸面或凹面,这取决于问题的角度,可能会遮挡或隐藏几何形状的其他部分。一些网格也可能具有密集的几何形状,顶点数在数十万或更多。对这些复杂的几何形状进行物理运算——当我们在这个上下文中提到物理时,我们主要指的是碰撞计算——是复杂的、不准确的,并且速度极慢。

为了在帧之间的短时间内使这些计算工作,我们必须用一个可以近似实际网格形状的简单几何形状来替代原始形状。这种近似通常是一个简单的形状,如BoxSphereCylinder。对于开发者来说,挑战在于选择最适合应用物理的网格的冒充者类型。

物理冒充者这个名字听起来很酷,但在功能上,它也可能被视为在处理物理引擎时代表网格进行操作的代理对象。它包含诸如质量、线性和角速度以及摩擦值等信息,正如你所预期的那样,但还有一个逻辑来控制冒充者如何在引擎和网格之间同步数据。

启用物理引擎的AmmoJSPlugin后,每个planetstarcargoUnitphysicsImpostor属性都将填充从gameData配置中读取的适当值,类似于这里所示:


this.star.physicsImpostor = new
  PhysicsImpostor(this.star.mesh,
  PhysicsImpostor.SphereImpostor, {
    mass: this.config.starData.mass,
    restitution: 0,
    disableBidirectionalTransformation: false,
}, this.scene);

一旦创建了冒充者,cargoUnit.physicsImpostor将订阅onCargoDestroyed方法处理程序,该处理程序负责将游戏状态从InFlight转换为CargoDestroyed

这是一段很大的铺垫,但结果却有点令人失望——物理和重力这样的复杂东西难道不应该更加复杂吗?也许应该是这样的,但多亏了很多人在很长的时间里付出了辛勤的努力,它不再是那样了!这确实是一个幸运的事情,因为它让我们能够更多地关注游戏机制以及它们如何与轨道模拟相匹配。

定义规则 - 游戏机制

典型的商业应用程序开发侧重于将应用程序的责任划分为逻辑段,这些段层层叠加,用户在一侧,而应用程序的基础设施在另一侧。数据和命令按顺序从一个层传递到另一个层,因为用户发起的事件与系统和应用程序事件同时传播。理想情况下,代码既有松散耦合的特点,又有紧密耦合的特点。

这可能听起来像是一个悖论或矛盾——同一时间如何既能松又能紧?答案是它可以同时做到,因为这两个特性往往相互关联。组件之间的松散耦合意味着对其中一个的更改对另一个的影响很小或没有影响。一个紧密耦合的系统是指功能被限制在少数几个代码或应用程序组件中;完成特定任务所需的一切都在手边。

在开发游戏时,我们努力以类似的方式将其结构化——不是因为它在类图中看起来很漂亮,而是因为它使得更改、扩展、修复和增强变得容易。现在,让我们提供一个关于基本游戏机制(也称为RoutePlanningScreen)的摘要。

可控发射参数

路线规划中主要游戏循环的一个关键部分是玩家应该能够控制他们发射的时机、角度和速度。这仅适用于ReadyToLaunch阶段。应强制执行最小和最大发射速度,最小和最大值的具体值由经验迭代(例如,试错)确定。

玩家应该能够通过视觉判断发射因素,无论是否有查看底层数据的帮助。如果玩家对他们的对齐方式不满意或想要重新开始,他们应该能够重置到起始参数。在支持输入控制部分,我们将查看输入映射,以了解玩家应该如何从他们的角度进行交互。接下来,我们将讨论玩家在游戏中可能成功或失败的情况,以及什么定义了特定的场景。

去往各个地方并无处不在地坠毁

在场景的gameData中,应该指定一个给定的游戏玩法中的origindestination行星。这些应该对玩家可见,以便他们知道自己在哪里以及他们需要去哪里。潜在的危害和障碍应该对玩家可见。在玩家选择将他们的货物发射到弹道轨迹后,如果CargoUnit接触到我们的Star或任何Planet,游戏将以失败状态结束(CargoDestroyed)。

如果玩家能够将他们的发射对齐,使其在某个半径内与目的地相交,他们将被认为是成功规划了他们的飞行路线。如果玩家选择拒绝给定的飞行计划,模拟将像其他地方一样重置。如果玩家接受飞行计划,游戏玩法将进入下一阶段。

未来游戏阶段以及得分机制将在本书的后续章节中介绍。要了解更多关于基本游戏设计的信息,Space-Truckers 的原版游戏设计文档可以在github.com/jelster/space-truckers/blob/develop/design/game-design-specs.md找到。虽然大部分已经过时,但它可以提供关于游戏元素如何随时间演变和成长的进一步见解,同时通过查看各种概念草图可能会带来一些乐趣。

支持输入控制

在上一章中,我们探讨了输入处理和控制系统。该系统定义了一个inputActionMap,将每个可能的输入映射到动作(命令)的名称。给定动作的具体含义和效果由实现该动作的代码以及特定于屏幕的代码决定。

让我们全面看看路线规划的控制方案。一些条目自上一章以来是新的;指针(触摸/鼠标)操作假设是围绕 GUI 元素进行的(参见使用 GUI 显示游戏信息部分),除非另有说明,相机控制使用原生键映射:

图片

GamePad控制方案以 Xbox®控制器为导向,但其他类型的控制器也可以通过少量努力得到支持——参见第五章添加场景和输入处理,以及inputActionMap.js文件中的gamePadControlMap常量,该文件位于github.com/jelster/space-truckers/blob/ch6/src/inputActionMaps.js

对于这些动作中的每一个,我们已经在本章的早期部分单独介绍了其功能实现,所以我们不会花费时间讨论其操作,因为我们只通过查看用户输入来查看反馈循环的一半。我们需要通过检查游戏向用户呈现的信息来关闭这个循环。

使用 GUI 显示游戏信息

当人们想到UIs时,首先想到的是图形界面——网页、开始菜单等。虽然视觉媒介是人与计算机之间沟通的主要手段之一,但音频和其他输出渠道也绝对是我们需要考虑的——只是现在还不是时候。我们将在下一章中加强视觉和听觉的环境效果。现在,让我们看看 GUI 是如何构建的。

与用于渲染我们的 GUI 元素的 AdvancedDynamicTexture 不同,这一点不会改变。然而,不同之处在于 SpaceTruckerPlanningScreen 包含一个 PlanningScreenGui 的实例(见 github.com/jelster/space-truckers/blob/ch6/src/route-planning/route-plan-gui.js)。反过来,PlanningScreenGui 在其构造函数中接受 SpaceTruckerPlanningScreen 实例,允许它访问所有需要动态更新 GUI 的数据。我们需要在场景加载完成并且屏幕完全构建后执行我们的 UI 初始化和配置;否则,我们的 GUI 将需要包含一个最终的意大利面式的条件和非空检查的混乱。

避免这种情况很简单:监听 scene.onReadyObservable,然后使用它来实例化 GUI。为了在构建和配置时间之间提供额外的灵活性,bindToScreen 函数创建了实际的 UI 组件,将显示对象与屏幕中的网格链接,并执行其他样板式的创建任务。这完成了 GUI 的静态配置,但我们希望——不,我们要求——GUI 应该与模拟和游戏的最新数据大致实时更新。这正是我们双管齐下的组合发挥作用的地方!

第一拳是通过事件订阅 SpaceTruckerPlanningScreen.onStateChangeObservable 并使用 onScreenStateChange 函数给出的:


this.planningScreen.onStateChangeObservable.add(state => {
    const currentState = state.currentState;
    this.onScreenStateChange(currentState);
}); 

这确保了每当游戏状态发生变化时,例如从 ReadyToLaunch 变为 InFlight,GUI 都会得到通知。该方法中的逻辑会查看 newState 以确定哪些控件应该可见,文本应该是什么颜色。这解决了需要协调 UI 变更与状态变更的问题,而这一拳击比喻的另一面则是作为 update 方法一部分进行的每帧控件更新的致命一击。

update 方法中,数字被格式化为显示格式,控件更新它们的文本属性,发射箭头根据当前的发射力进行缩放。基本上,任何不直接影响游戏的内容都会在这个方法中更新。需要显示动态更新数据的问题也得到了解决——我们可以停止我们坚定不移的要求,并宣布胜利!

这是一件好事,我们不仅因为我们在相当少的文字中涵盖了大量的信息,而且因为拳击比喻对于太空卡车来说非常不符合品牌形象,而且它已经变得相当陈旧。让我们回顾一下我们刚刚讨论的内容,并看看一些关于你可以如何练习使用这些概念的想法。

摘要

我们在完成SpaceTruckerSoundManager后开始了这一章,它维护了一个所有声音资产的内部目录,并使这些资产可供想要播放声音的托管屏幕使用。尽管它看起来做了很多事情,但当涉及到实际的 Babylon.js 声音时,它喜欢将责任委托给声音。

通过SpaceTruckerSoundManager定义的不同 SoundTracks 提供了混合不同声音和来源的能力,并且它们使得在不需要编写关于音量级别的逻辑代码的情况下同时播放背景音乐和游戏音效变得非常容易。这是因为每个声轨都有一个音量(增益)控制。

在回顾了GameObject类层次结构之后,我们深入——或者更准确地说,是跌入——了渲染演员构建的具体细节。准备好工具箱后,我们创建了SpaceTruckerPlanningScreen并设置了一系列状态及其之间的转换。从那里,我们跳入了基本的引力物理学——这听起来是不是很酷?找到一种方法,在工作中的下一次对话中提及你了解引力物理学,并深思熟虑地抚摸你的下巴。

然后,我们了解了一些关于Ammo.js物理引擎如何设置和配置到我们的项目中的信息。在将行星设置为运动状态后,我们将注意力转向了添加一些游戏元素。玩家控制的发射参数、碰撞检测以及显示玩家的统计数据都迅速进入了我们的清醒意识,留下我们面对前进的道路。

在下一章中,我们将从我们将放置的粒子系统中爆发太阳耀斑和 prominence。稍后,我们将探索遭遇区域,并在完善路线规划并准备游戏下一阶段的同时,专注于捕获路线数据。

扩展主题

你是否还没有准备好进入下一章?你是否在试图弄清楚你刚刚阅读的所有内容是如何工作的时遇到了困难?跳转到 Space-Truckers 讨论板(github.com/jelster/space-truckers/discussions)去搜索答案或发布你的问题,以便其他人可能能够帮助你解答。如果你还没有准备好继续前进,但感觉你对事物有了很好的掌握,为什么不尝试用这些想法来增强屏幕呢:

  • 当游戏开始时,让摄像机从一个很远的地方开始,然后拉向恒星,在这样做的同时对系统进行一次巡游,最后结束在起始摄像机位置。有好多方法可以完成这个任务,但一个潜在的方法是创建一个动画和一系列关键帧来指定摄像机的位置。另一种可能的方法是使用autoFramingBehavior,同时调整摄像机的惯性和其他相关值。

  • 使游戏手柄的扳机可用于调整发射力;拉动左侧可以减小力,而拉动右侧可以增加力。本质上,这会与当前的按钮按下方式相同,只是不断增加的常数值会被缩放或替换为扳机值(扳机是正轴,而另一个是负轴)。

  • 认为物理计算太不准确了吗?你对矩阵数学的复杂性嗤之以鼻吗?当有人说你不合理时,你开始质疑自己的存在吗?好吧,这里有一个挑战:在模拟中根据deltaTime调整力的部分添加一个基本的数值积分器。

(较简单)使用欧拉方法通过当前速度和帧的deltaTime以及前一帧的速度和位置来计算货物的新的/未来的位置(en.wikipedia.org/wiki/Euler_method)。

(较难)使用 Verlet 积分来完成同样的任务(en.wikipedia.org/wiki/Verlet_integration)。

  • 好吧,也许最后一个要求有点过于严格,但仍然有一种冲动想要做点什么来让代码不那么糟糕……这里有一个挑战,它并不特别要求了解上述任何主题,但确实需要工程经验和优秀的源代码理解能力:将核心引力模拟集成到 Babylon.js 物理插件/引擎中。

  • 如果你完成了这些事情中的任何一项,请务必通过在 Space-Truckers 讨论区、Babylon.js 论坛上发布链接或通过提交 Pull Request 的方式与世界分享你的工作!

第七章:处理路线数据

尽管我们目前不会探讨路线规划和路线驾驶之间的过渡,但从大局来看,生成路线是 Space-Truckers 游戏玩法的一个关键部分。在本章中,我们将继续之前的做法,简要地偏离到一个相关的话题——在这种情况下,这个话题将是使用一些粒子系统来美化太阳渲染。

在我们对粒子系统的偏离之后,我们将直接探讨如何从路线规划中捕获、处理和整合数据,形成一个基于位置的丰富遭遇集,这将随后驱动下一阶段的玩家挑战。

使这一切成为可能的是一种技术,其根源可以追溯到 RPG 的最早时期——当黑暗的地下城充满了危险的龙,玩家在遭遇表中掷骰子以决定他们的命运时。Space-Truckers 的遭遇表按区域分类,并在决定 Space-Trucker 的命运中扮演着类似的角色。每个区域都有一系列可能的遭遇,以及该遭遇发生的基概率或机会。大多数遭遇都携带玩家必须采取行动以避免或减轻的潜在危险,而更少的情况下,其他遭遇可能会有益的效果(如果管理得当)。

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

  • 偏离到实用系统

  • 标记路线

  • 定义遭遇区域

  • 选择遭遇

  • 添加遭遇视觉效果

到本章结束时,我们将稍微美化一下路线规划环境,但就应用覆盖的区域而言,对最终用户体验的实质性影响不会很大。这是可以的——它最终会产生巨大的影响!然而,为了实现这一点,我们必须构建一些逻辑来处理和准备路线以供遭遇使用。

技术要求

本章的技术先决条件并没有太大的不同,但有一些概念和技术可能对本章的主题有用。

如果你感到迷茫、被复杂性压倒或对某个特定领域有困难,以下是一些需要研究的话题:

本章的源代码位于github.com/jelster/space-truckers/tree/ch7,其中包含本章以及之前章节的所有工作。此外,还有一些独立的改进、错误修复和调整,这些内容在本书之前的内容中并未涉及。虽然如果能详细讨论这些内容会很好,但在现有的时间和空间内这是不可能的!然而,在相关的地方,这些变化将会被指出。然而,大多数变化并没有引入任何新的概念或技术,而是对现有的内容进行了细化、修复或增强。

粒子系统的小插曲

粒子系统是图形编程的一个领域,就像输入处理这个主题一样,可以有一整本书专门介绍粒子系统,从基本理论到具体实现。我们在这里不会深入到那个程度,因为我们还有许多其他事情要做,除了学习粒子系统!以下是你需要了解的关于粒子系统的一般知识。稍后,我们将探讨它们与 Babylon.js 的关系,以及我们如何利用它们带来乐趣和利润。

想想你最近玩过的最后一款视频游戏。如果是一款基于文本的游戏,那么就想想你最近玩过的非文本游戏。游戏中有没有爆炸效果?有没有爆炸的魔法火球?又或者是烟花或篝火?这些都是游戏开发者可能会使用粒子系统的例子。

让我们回顾一下。粒子是一个具有离散生命周期(创建和死亡)的单一实体。它通常不是用一个网格表示,而是用一个纹理表示,因为大多数粒子是二维的横幅纹理或精灵。纹理或图像具有透明度属性,以不同的方式与其他场景混合。如果“透明度属性”这个词让你感到困惑,那么回忆一下透明度指的是 alpha 通道,这个通道的属性是向引擎发出的指令,告诉它如何将这个通道与重叠的颜色混合或混合。这意味着,通常,粒子总是以直接面向摄像机的方向排列,并且它将具有淡入淡出的能力。

粒子系统不仅仅是粒子的集合。粒子系统定义并控制其组成粒子的整个生命周期。它通过几个主要机制来完成这项工作:

  • 发射器:粒子开始其生命周期的网格或节点。发射器的不同属性允许对网格的一部分和发射形状进行粒度控制,以及发射的粒子数量和发射速率。

  • 粒子属性:包括视觉和行为属性,前者包括大小、缩放、旋转、颜色和速度,后者包括寿命。

  • 动画、噪声和混合效果:向系统中添加噪声可以增强粒子系统的真实感,而动画则提供了动态的外观和感觉。

如果一个粒子系统由粒子组成,那么粒子系统的集合又是什么呢?这被称为粒子系统集,这也是我们将要使用的东西,以给 Space-Truckers 的太阳增添一点“光辉”!

重要提示

那最后一句话可能有点过头了。

使用粒子系统集的优势在于我们可以使用单一的综合逻辑来一次性加载、启动和停止所有系统。尽管我们将在不久的将来使用粒子系统集,但在 Babylon.js 中还有其他几种不同但相关的生成和管理粒子的方法。

Babylon.js 的不同粒子系统

BJS 粒子系统的家族树并不像希腊众神那样复杂,但它与那个传奇的家谱共享的是代际分离。

“经典”CPU 粒子系统

这就是每个人都知道、喜爱和熟悉的东西。三种的原始风味,这为最终开发者(即你)提供了对粒子行为每个方面的最大程序控制。因为它每帧都在 CPU 上运行,所以它必须与其他需要在帧之间发生的事情共享帧预算中的时间。如果目标帧率为 60 FPS,那么帧内预算仅为 1/60 秒或略低于 17 毫秒。正如任何中间的孩子兄弟所知,妥协是关键!

“新浪潮”GPU 粒子系统

由于图形加速器在今天的计算环境中几乎无处不在(由某个 Web-GPU JavaScript 框架……为证),因此用于编程它们的工具也变得更加强大。稍后,在第十一章表面下的着色器中,我们将更详细地探讨如何轻松有效地利用这种力量来娱乐(以及盈利!)但就目前而言,相关的事实是,我们以前在 CPU 上运行的相同粒子系统现在直接在 GPU 上执行和更新。这种变化的最大影响是可用粒子的数量急剧增加。不再需要担心几百个粒子对性能的影响,当粒子数量达到数万时,同样的担忧才开始出现——这是一个相当大的改进!

“硬核”固态粒子系统

当踏板触碰到路面,事情变得真实起来时,就是时候拿出杀手锏了。固体粒子系统SPS)是由一个三维网格构成的,而不是由点状粒子构成的。每个粒子实例必须与其他 SPS 实例共享相同的材质,但其他属性,如位置、缩放、旋转等,都由开发者控制。SPS 粒子可以启用物理效果,并提供碰撞支持。这种程度控制和细节的缺点是,每个属性都必须单独设置和控制——这与根据与之关联的各种属性值演变的常规粒子系统不同。为系统硬编码单个值是繁琐的、容易出错的,而且维护起来也不是很有趣。对于游乐场和原型来说,这样做是可以的,但对我们应用来说,我们希望能够将粒子系统表示为可以独立于应用程序行为进行管理的数据。

从保存的数据加载粒子系统

当处理 CPU 或 GPU 粒子系统时,通过代码逐个输入和调整每个特定属性可能会非常繁琐且容易出错。万能的 Babylon.js 检查器(赞美其疯狂的好性质!)可能是迭代不同属性值以实时查看它们的最快方式,但如何有效地捕捉粒子系统集中每个粒子的每个属性当前状态,一开始可能看起来很困难。然而,就像 Babylon.js 中的许多事情一样,有多种方法可以实现相同的目标。幸运的是,有多种方法可以得到相同的结果;它们都利用了ParticleHelper的不同方法。所有三种方法都在检查器中可用(见图 7.2),可以根据项目需求选择最合适的方法。

ExportSet/保存到文件(检查器)

首先是纯粹程序化的方法,即调用ParticleHelper.ExportSet(setA, setB,…)。该函数的输出是一个 JSON 字符串,然后可以将其保存到文件或存储在其他地方。在将多个系统组合在一起后,在游乐场中使用此方法最简单。使用检查器,可以通过在场景资源管理器中选择所需的系统,然后在文件标题下的保存…按钮处点击,将单个系统保存为 JSON。这对于单个系统设置很有用,但为了将多个系统保存到文件,ExportSet是最佳选择。

将代码片段保存到代码片段服务器(检查器)

在浏览器中打开 Babylon.js Playground(这里有一个参考链接:playground.babylonjs.com/#735KL4#15)——注意特定的 Playground 是通过一个唯一的组合来识别的,包括哈希(#735KL4 部分)和修订版(#15)。嗯,以这种方式使 Playground 资源可引用的想法非常成功,以至于这个概念已经扩展到 Babylon.js 的许多其他领域。

想在 ParticleHelper.CreateFromSnippetAsync 中加载 GUI 设置。您可以在官方文档中了解更多关于粒子系统片段服务器的内容:doc.babylonjs.com/divingDeeper/particles/particle_system/particle_snippets

使用内置的粒子系统集

亚里士多德在他那个时代就已经是一位有影响力的人物,他关于事物由四种“元素”组成——空气、土地、火焰和水——的观点被广泛接受为真理,这主要归功于良好的营销。在这种精神下,Babylon.js 为您提供了一个基本的“元素”粒子系统集目录,供您使用。以下是可用的效果(更多关于它们的信息,请参阅文档中的doc.babylonjs.com/divingDeeper/particles/particle_system/particleHelper#available-effects):

  • 爆炸:非常适合用来炸毁东西。

  • :为额外的忧郁感加分。

  • 烟雾:用于表示新教皇的选择非常有用,但也适用于许多其他事情。记住,有烟的地方,通常……

  • 火焰:无论是篝火、火炬还是传统的房屋火灾,这里都是开始的地方。

  • 太阳:大奖!这个粒子集包括耀斑、动态变化的面部,以及光环的模糊光芒。但有一个问题……

备注

还有一个理论认为事物由这些微小、不可分割的粒子组成,称为(ἄτομος,或atomos),但其主要支持者德谟克利特并不像亚里士多德那样受欢迎,所以没有人听他的。现在谁才是最后的赢家,亚里士多德?

提到的这个问题是什么?这不是一个大问题。它有点——不,它正是大问题的反面。这是一个小问题,一个是规模问题。太阳效果非常适合我们的需求,但它太小了,太小了,太小了。我们需要能够将其放大以匹配我们的天文比例,但它的缩放方式或位置不会很精确——这需要一些实验。在 playground.babylonjs.com/#J9J6CG#9 的 Playground 中展示了最终被纳入 Space-Truckers 代码库 JSON 数据中的调整。

备注

虽然在本书中包含所有各种游戏设计方面和涉及的决定是理想的,但事先预知一切是不可能的。此外,本书的大小也有实际的限制。因此,在适用的情况下,将提供指向 GitHub Issues 的链接,这些 Issues 提供有关功能或游戏部分的详细信息。GitHub 中的 Issues 可以链接到其他 Issues 和 Pull Requests(以及其他内容),这使得快速评估和评估与特定 Issue 或功能相关的代码变得容易。与我们当前的工作相关,这个问题 – 星系应该偶尔出现耀斑和活动 (github.com/jelster/space-truckers/issues/71) – 从 Babylon.js 文档和 Playground 片段中汇总链接,以提供对期望结果的见解。评论和链接的 Pull Requests 展示了问题的历史和演变。这并不是说我们不会涵盖游戏设计或其细节——远非如此!简单来说,软件中的事物以与其他商品和行业(如出版业)不成比例的速度发展和变化。希望了解游戏如何演变的人可以通过阅读记录该变化的 Issues 来实现。

在我们可以深入了解这些变化之前,我们必须弄清楚如何从 JSON 文件加载和启动粒子系统集。尝试这样做会有摩擦。ParticleHelper 是设计和构建的,重点是减少开发者的复杂性,其中某些方面可能成为我们目标的障碍。

从 JSON 文件解析

其中一个最终成为障碍的便利功能是 PracticalHelperCreateAsync 方法仅接受一个字符串,表示要创建的系统的类型——即“雨”、“烟”、“太阳”等等。然后,这个字符串将与 ParticleHelper.BaseAssetsUrl 结合使用,以构建 JSON 文件的完整 URL。除非明确覆盖,否则 BaseAssetsUrl 的值为 github.com/BabylonJS/Assets/tree/master/particles/。文件夹的结构将 JSON 文件放在 /systems 子文件夹中,并将纹理放在 /textures 子文件夹中——这是一个很好的、一致的约定,对于大多数用例都工作得很好,但不是我们的情况。与我们的设置冲突的主要问题如下:

  • 我们的文件夹结构与常规假设的不同

  • 多个资源需要使用相同的纹理

  • 我们正在使用 webpack 来打包和管理我们的资源和依赖项,因此我们的设计时文件夹结构略不同于运行时

  • 依赖于外部来源的核心游戏资源和数据会复杂化并阻止离线/本地/PWA 类型的场景

第一项和最后一项可以通过使用相对路径和覆盖BaseAssetsUrl并使其类似于document.baseURI来在一定程度上缓解。然而,中间两项需要更多的思考来解决。检查ParticleHelper的源代码(见github.com/BabylonJS/Babylon.js/blob/master/packages/dev/core/src/Particles/particleHelper.ts)揭示,没有实际的方法可以覆盖计算 JSON 文件 URL 的传统逻辑。然而,一旦过了这一步,解析和初始化粒子系统集就非常直接了。这里的问题并不是我们不能使用传统方法,而是由于 webpack,我们不需要找出如何加载 JSON 数据——我们已经有它了,而ParticleHelper期望需要检索相同的。是时候开始像 1980 年代中期的每个人都喜欢的领域工程师 MacGyver 一样思考了。

MacGyver 会怎么做WWMD)?MacGyver 最大的优势并不是他高大威猛,或者他能在拳击中踢坏坏蛋的屁股。甚至不是他那让奥林匹克神(或女神!)都嫉妒的华丽长发。不,MacGyver 最大的优势是他能够通过建造、破解或以其他方式科学地摆脱他发现自己陷入的几乎所有困境。通过关注周围的环境,然后应用他对各个领域(广泛)的(广泛)知识,他证明了敏锐的视角和聪明的头脑可以克服几乎任何障碍。让我们用 MacGyver 的视角来看这个问题:

在炸弹爆炸之前,我们需要获取一个粒子系统集!JSON 数据已经加载,但 ParticleHelper 需要 URL 字符串,而且只剩下最后一分钟,然后一切都会爆炸…我们还剩下什么,看看…啊!将对象数据传递给 ParticleSystemSet.Parse,完全绕过 ParticleHelper,但必须快速——我们正在耗尽时间!

所以,根据上面的 MG(MG 在这里),我们不想使用ParticleSystemSet.Parse,因为那就是Parse方法(doc.babylonjs.com/typedoc/classes/babylon.particlesystemset#parse)。知道我们是从一个从正确的定义文件反序列化的普通 JavaScript 对象开始,他得出了一个相当明显(事后看来,自然如此)的结论:由于结果(一个name属性。多亏了 MG,我们有了所需的工具,能够将太阳粒子系统集与我们的应用程序集成!

适配太阳粒子系统集

概念验证游乐场(playground.babylonjs.com/#J9J6CG#9)让我们对游戏中要缩放的物体有一个大致的了解,但要实现我们想要的太阳系统,还有更多工作要做。游乐场只有一个三个粒子系统中的一个 – 焰系统 – 而还有另外两个;也就是说,太阳和眩光系统。这些也必须正确缩放和配置。完成这项工作的最佳方式是按照以下步骤进行:

  1. 前往 Babylon.js 资产仓库,并将所需的 JSON 和纹理文件保存到本地仓库中。例如,日落位于github.com/BabylonJS/Assets/blob/master/particles/systems/sun.json

  2. 打开sun.json文件,并将纹理路径更改为反映项目的文件夹结构。使用相对路径,但请确保考虑消耗脚本的相对路径,而不是 JSON 文件的路径。在Star类中,添加必要的代码来加载并启动集合(见github.com/jelster/space-truckers/blob/ch7/src/route-planning/star.js#L26)。

  3. 在应用程序运行并在适当的屏幕上时,通过按适当的键组合(默认为Shift + Alt + I)打开检查器窗口。修改系统的属性,并等待更改生效。

  4. 更新各个系统的属性,使其与所需值匹配。

  5. 转到(3)。

结果将是你认为看起来最酷的东西,但如果你想从现有的定义开始,或者只是跟随现有的定义,你可以在github.com/jelster/space-truckers/blob/ch7/src/systems/sun.json找到它:

图 7.1 – Sun 粒子系统适应了 Space-Truckers 的规模之后。检查器窗口对于实时看到不同值的效果至关重要

图 7.1 – 在 Sun 粒子系统适应了 Space-Truckers 的规模之后。检查器窗口对于实时看到不同值的效果至关重要

通常,可能需要增加太阳和眩光粒子的数量,但无论变化如何,都要确保等待几秒钟,以便它传播到新产生的粒子,因为有些粒子的寿命可能只有几秒钟!

在本节中,我们了解了 Babylon.js 中可用的不同类型的粒子系统,以及一些快速迭代以找到我们想要的视觉效果的技术。希望我们通过发挥老麦克的聪明才智,找到了加载和适应在飞行中穿越系统的 Sun CargoUnit问题的解决方案,让他感到自豪。

标记路线

Space-Truckers 游戏玩法的一个关键方面是 CargoUnit 在路线规划阶段所采取的路径如何影响驾驶阶段的挑战——以及奖励。我们已经处理了路线规划的弹道飞行力学,所以现在,我们需要捕捉那条路线以及它穿越的环境数据。以下图表显示了我们的路线的主要属性以及它们可能如何表示:

![图 7.2 – 在路线规划飞行阶段捕获了各种遥测数据。每个样本都收集了位置、旋转、速度和时间戳]

图片

图 7.2 – 在路线规划飞行阶段捕获了各种遥测数据。每个样本都收集了位置、旋转、速度和时间戳]

在这里,CargoUnit 负责保存其路径的想法,这转化为 CargoUnit 类,从而获得一个新的 routePath[] 属性,以及 reset()update() 方法中的相关逻辑来清除和更新路径,分别。数据本身很简单,尽管我们很快就会涉及到 encounterZone 字段:


let node = new TransformNode("cargoNode", this.scene, 
    true);
node.position = this.mesh.position.clone();
node.rotationQuaternion = this.mesh.rotationQuaternion?.
    clone() ?? 
    Quaternion.FromEulerVector(this.rotation.clone());
node.scaling = this.lastVelocity.clone();
node.velocity = this.lastVelocity.clone();
node.gravity = this.lastGravity.clone();
node.time = this.timeInTransit;
node.encounterZone = this.encounterManager.
    currentZone?.name;

TransformNodeTransformNode 中一个非渲染对象,在 添加遭遇视觉元素 部分很有用。因为它们实现了计算和放置节点在世界场景中的位置所需的一切,TransformNodes 在许多不同的应用中都很有用。这包括能够成为场景中其他对象的父节点和/或子节点。一些例子包括通过父化 PhysicsImpostor 制作的“摄像机推车”。

由于这段代码紧跟在我们刚刚计算出的速度、重力和旋转属性之后,我们确保我们有最新和最准确的数据。为什么我们要将旋转存储为 四元数 而不是我们已有的 Vector3 表示形式呢?原因是我们将想要在 局部空间 而不是 世界空间 中对网格顶点执行一些数学变换,而预先计算好的四元数使得计算更加简单,同时也更加高效。

重要提示

不要忘记 JavaScript 引用类型是按引用分配的,而不是按值分配——因此需要克隆 Vector3 属性值。

虽然捕捉路径遥测数据的工作已经完成,但在这些数据开始在游戏中变得有用之前,还有更多的工作要做。其中一项工作就是实现遭遇表及其相关的遭遇区域的概念。之后,我们就可以开始将这两个概念整合到SpaceTruckerEncounterManager中。如果您想深入了解我们将要讨论的不同组件及其高级设计的历史和联系,github.com/jelster/space-truckers/issues/70是一个不错的起点。

定义遭遇区域

遭遇表正如其名所示:它是一张基于随机因素的某些事件发生的概率表格。在桌面和 RPG 风格的游戏中,随机因素由掷一个或多个不同面数的骰子提供。在基于计算机的游戏中,情况相同,只是我们不会掷物理骰子,而是根据随机数生成器的输出生成遭遇。

与游戏中的许多其他对象一样,遭遇区域EZs)是可更新的游戏组件,而每个遭遇都充当定义该遭遇的数据容器。这使得遭遇管理器可以选择哪个 EZ 应该负责运行遭遇检查,从而简化了在 EZ 中所需的逻辑。简单,对吧?

遭遇概述

遭遇表的架构很简单。每一行都是游戏设计师希望使其成为可能的具体事件或遭遇。表格中的概率列表示该事件发生的可能性,以 0(完全没有机会)到 1(保证发生)之间的数字形式表示。这是一个好的开始,但我们还需要能够根据它们在世界中的空间位置进一步对遭遇进行分组;在暗淡的外层系统区域遭遇太阳耀斑有什么意义吗?这就是遭遇区域概念的作用所在。

遭遇区域和遭遇表

遭遇区域是针对游戏世界中特定空间位置的遭遇表,如前所述。从内系统到外层区域,每个遭遇区域都有独特的潜在遭遇供玩家应对——或者从中受益!以下是一个按区域分组的遭遇表,它是 Space-Truckers 游戏设计规范的一部分。虽然不完整且故意模糊具体细节,但它仍然清楚地展示了该功能应该如何工作以及与其他功能的交互:

图 7.3 – Space-Truckers 遭遇的设计。来源:https://github.com/jelster/space-truckers/issues/65

图 7.3 – Space-Truckers 遭遇的设计。来源:github.com/jelster/space-truckers/issues/65

在实现遭遇时,将会有不同的需求,因此每种遭遇类型都会有不同结构的解决方案。幸运的是,我们目前不需要定义那些具体细节,所以我们将暂时将其搁置一边,退一步看看遭遇区域如何跟踪货物单元

跟踪交集

每个 EZ 都需要为CargoUnit网格的动作管理器注册交集退出和进入触发器,但我们不希望为每个区域编写代码来做这件事——如果我们改变EncounterZones的数量,或者想要改变交集的使用方式怎么办?幸运的是,这个问题可以很容易地解决。

当调用SpaceTruckerEncounterManagerinitialize方法时,会遍历encounterZones列表,使用forEach循环。在其它动作中,每个区域都会通过cargo.mesh作为参数传递给其registerZoneIntersectionTrigger方法。这个函数在meshToWatch.actionManager上执行交集注册,将相应的OnIntersectionExitTriggerOnIntersectionEnterTrigger分别连接到遭遇区域的onExitObservableonEnterObservable

注意

SpaceTruckerEncounterManagerCargoUnit的一个成员。

SpaceTruckerEncounterManager的主要目的是(正如其名称所暗示的)管理其构成区域中的遭遇,但为了能够做到这一点,它需要知道当前正在穿越的哪个 EZ cargoUnit。你可能会最初推测,由于EncounterZone具有环面形状,嵌套(但不重叠)的区域只有在穿过网格时才会触发它们的交集触发器,但在实践中并非如此。

对复杂网格进行交集计算是一个非常计算密集的过程,这使得它不适合用于实时处理应用。相反,Babylon.js 所做的是使用成本更低且计算效率更高的边界框交集计算。虽然速度快,但它们并不能非常准确地模拟正在测试的实际几何形状,从而导致一个问题,即货物单元似乎对应用程序来说不仅位于其位置区域,还位于其周围所有嵌套区域!

为了解决这个问题,SpaceTruckerEncounterManager通过inAndOut字段跟踪所有触发的交集。每当区域发出进入信号时,该字段就会增加,对于相反的情况则减少,它是一个表示currentZone索引的整数,该索引偏移量为遭遇区域的总数:


get currentZone() {
    let zidx = this.encounterZones.length - this.inAndOut;
    return this.encounterZones[zidx]?.zone;
}

此属性在多个区域中使用,从CargoUnit到路线规划 GUI,但遭遇管理器中的底层区域需要预先用定义每个区域边界和特征的数据进行填充。

遭遇区域和游戏数据

遭遇区域(就像大多数软件组件一样)由其行为和数据定义。数据来自遭遇区域的定义,看起来像这样:


asteroidBelt: {
    id: "asteroid_belt",
    name: "Asteroid Belt",
    innerBoundary: 1000,
    outerBoundary: 1700,
    encounterRate: 0.2,
    colorCode: "#ff0000",
    encounters: [
        { id: 'rock_hazard', name: 'Rock Hazard', image
            hazard_icon, probability: 0.90 },
        { name: '', id: 'no_encounter', probability: 0.1, 
            image: '' }
    ]
}

在构建时,SpaceTruckerEncounterZone使用这个结构(作为构造函数的参数传递)来初始化和配置 EZ。一些属性是自解释的,但innerBoundaryouterBoundary需要明确的定义,以及encounterRate。一旦我们覆盖了这些,我们将深入探讨遭遇数组及其工作方式。

innerBoundary字段是环面描述的外圆的半径。虽然从概念上看这是合理的,但它与 Babylon.js 的createTorus方法有所不同,该方法是控制网格大小的“旋钮和开关”,主要参数是diameterthickness。这两个值听起来如果我们传递outerBoundary(outerBoundary – innerBoundary),应该会工作得很好,但仔细阅读参数描述告诉我们一个不同的故事。

最直观的方式是通过取一段电线,将其弯曲成半径为2 * r的半圆来想象各种参数如何相互配合。现在,想象一下取一个半径为2 * R的小纸垫。环面的外边界并不等同于直径参数——它等于直径加上一半的厚度。内边界等于直径的一半减去一半的厚度。这不是最好的描述方式,但这是描述它的一种方法!这里有一个比描述更好的东西——一个用于该类比的可视化,可在标题中的游乐场链接找到:

图 7.4 – 环面(torus)的性质。直径由一个实心圆表示,其厚度由一个较小的圆圈描述。这个游乐场可以在 https://playground.babylonjs.com/#P2YP2E#1 找到

]

图 7.4 – 环面(torus)的性质。直径由一个实心圆表示,其厚度由一个较小的圆圈描述。这个游乐场可以在playground.babylonjs.com/#P2YP2E#1找到。

为什么我们要经历这些弯弯绕绕?因为以这种方式构建,我们可以快速轻松地比较和调整遭遇区域与行星轨道,正如在gameData中定义的那样。

最后,回到 EZ 数据,encounterRate字段是一个百分比(0 – 1)数字,表示该区域一般发生遭遇的频率。每个区域独立保持自己的遭遇表,然后使用该表来确定可能发生哪些遭遇。既然我们谈论到了遭遇和随机数,我们不妨尝试理解如何具体实现从遭遇表中选择条目的逻辑。为此,我们需要讨论一个叫做累积分布质量函数的东西。

选择遭遇

首次实现此类功能的开发者可能会设计一个简单的函数getEncounter,该函数在搜索具有小于或等于骰子点数的概率的第一个遭遇之前,先随机选择一个数字作为骰子点数。让那位开发者感到沮丧的是,这种简单的方法也是不正确的!虽然这种方法在掷骰子以确定单一遭遇的机会时是有效的,但当存在多个潜在的遭遇时,它将不起作用。以下是简化形式的小行星带遭遇表


encounters: [
            { id: 'rock_hazard', name: 'Rock Hazard',
                 image: hazard_icon, probability: 0.90 },
            { name: '', id: 'no_encounter', probability: 
                0.1, image: '' }
        ]

遭遇表中的每一项都有一个相关的概率因子,其总和通常(但并非必须,因为我们将很快编写一些代码)等于 1(100%)。当你想要从表中随机选择一个条目时,必须考虑所有可能发生的事件。将计算事件输出响应随机数输入的过程称为累积(质量)分布函数CMDF)。在EncounterZone源代码(见github.com/jelster/space-truckers/blob/ch7/src/encounterZone.js#L44)中,CMDF 是通过构造函数实现的,它是一个两步过程。

求和概率

在第一步中,我们计算所有单个遭遇的概率总和。这一步将允许应用程序处理单个概率不都加到 1 的情况,并在第二步中使用。当我们到达那里时,遭遇表从定义中填充:


var total = 0;
definition.encounters.forEach((e, i) => {
    total += e.probability;
    this.encounterTable.push(e);
});

这一步的目的在于,虽然我们无法保证概率的总和一定会达到 1,但我们可以在下一步中归一化这个总和,以便表中的每个条目都能在 CMDF 中得到正确和成比例的表示。

填充 CMDF 结果

第二步涉及再次遍历encounters列表(在预先烘焙cumulativeDistribution数组的第一个元素之后)并将条目填充到第二个数组中——即上述的cumulativeDistribution数组。这个集合的条目代表了整个空间的CMDF,因此可以用作索引来查找任意输入的值:


this.cumulativeDistribution[0] = this.encounterTable[0].
    Probability / total;
for (var I = 1; i < definition.encounters.length; i++) {
    this.cumulativeDistribution[i] = 
        this.cumulativeDistribution[i - 1] + 
            definition.encounters[i].probability / total;
}

注意,由于循环是向后的,第一个元素是在循环之外计算的,然后循环从this.cumulativeDistribution[i]等于前一个元素的值加上当前遭遇的probability份额对total的份额开始。这只需要在初始化时发生一次。一旦到位,现在就可以“掷骰子”并实现更正确的getEncounter形式。

掷遭遇检查

每次调用 update 方法时,逻辑将评估是否发生了遭遇,然后决定将要发生哪个遭遇。它需要考虑自上一帧以来经过的时间,因为它会将遭遇与玩家的帧率联系起来——这不是我们想要的!一旦考虑了这一点,并且确实有区域指示遭遇,就会调用 getEncounter 方法从 encounterTable 中检索一个随机条目。检索到的遭遇随后作为事件参数传递给 onEncounterObservable,让任何订阅者都知道 encounter


const encounterProbability = this.encounterRate * deltaTime;
if (Math.random() < encounterProbability) {
    let encounter = this.getEncounter();
    console.log('encounter ' + encounter?.name);
    this.onEncounterObservable.notifyObservers(encounter);
} 

这就是整个更新循环。如果生活总是像这些解决方案一样优雅和简单,也许人们会相处得更好,因为 getEncounter 方法简化为一条正确、尽管有些晦涩的 JavaScript 代码:


for (var i = 0; i < this.cumulativeDistribution.length && (diceRoll > this.cumulativeDistribution[i]); i++) {};  

这之所以有点晦涩,是因为你可能已经注意到,for 循环没有主体!循环中没有主体,简单来说,是因为没有这个必要。循环的目的是找到符合 diceRoll 数字的索引 (i)。一旦这个条件得到满足,i 值由于使用 var 而不是 let 声明而保留下来。遭遇本身作为索引被检索并返回给调用方法以进行分发。

监听遭遇

一旦 SpaceTruckerEncounterManager 被用作经纪人并聚合器,在 onEncounter 观察者方法中分发 Encounter 的消息,同样的观察者被订阅到所有区域的 onEncounterObservable,这为我们提供了所需的事件聚合,以及 lastFlightPoint 遥测包。

遭遇和 cargoData 然后被打包在一起并推入 encounterEvents 数组以供将来参考。新添加元素的下标随后被传播给 onNewEncounterObservable 的观察者:


const cargoData = this.cargo.lastFlightPoint;
const idx = this.encounterEvents.push({ encounter,
    cargoData });
this.onNewEncounterObservable.notifyObservers(idx - 1);

我们向 encounterEvents 集合传递索引(或指针)的原因是我们想确保我们可以干净利落地在任何时候处理这些对象;如果对象被传递到事件中,系统可能无法确定是否可以从已处理的对象中释放内存——这种情况称为内存泄漏。

到目前为止,我们已经检查和讨论了定义、定位和生成不同类型遭遇所需的基础设施。这些遭遇将在本书的驾驶阶段游戏逻辑中详细介绍。然而,在我们对遭遇的理解仍然新鲜的时候,让我们看看遭遇如何在路线规划屏幕的上下文中被使用和展示。

添加遭遇视觉效果

这是我们之前在标记路线部分的工作发挥作用的时刻。回想一下,当我们的CargoUnit沿着轨迹下落时,它不断地留下一条面包屑线来标记其路径。这是通过CargoUnit.trailMesh组件可视化的,除了在场景重置时需要初始化和销毁,它几乎不需要我们干预。我们需要一种类似的无为而治的方式来渲染沿路径发生的遭遇的视觉效果,这正是我们刚刚介绍的工作旨在实现的。

重要提示:

虽然以下部分最终被从游戏中删除,但演示的技术值得保留在口袋里。

将 2D 标签放入 3D 空间

虽然 Babylon.js 中有一个 3D GUI 系统,但我们的当前需求不需要使用完整的 3D UI。然而,3D GUI 的一个优点是,它很容易在 World Space 中定位元素——原因应该很清楚。

注意

与笑话不同,解释这个不会有风险。明显的理由是,由于需要结合相机位置、世界位置和屏幕位置变换来获得正确的坐标,而不是在相同坐标空间中运行的 3D GUI 系统,因此关于 3D 世界空间点的 2D 元素定位可能会变得复杂。

坐标变换固有的复杂性,幸运的是,被 BJS GUI 框架隐藏起来,对开发者来说——linkWithMeshmoveToVector都允许调用者将 GUI 控件放置在世界空间中的某个位置。这在某种程度上是好的,但我们仍然需要有一个地方来挂载视觉效果,并为未来的增强和行为提供基础。

如果你是在休息后回到这一节的,你可以感谢过去的自己把所有这些部件都放到了合适的位置。如果你一直在快速浏览这一章(不要停下来——不会停下来——不能停下来!),那么请给自己鼓掌。花时间正确地认可自己和之前行为对当前情况的影响——无论是好是坏——都是非常重要的。换句话说,这将会很容易。

记得我们是如何使用TransformNode来追踪我们的货物飞行路径的吗?这就是最终证明那个决定是正确的时刻。大多数linkWithMesh函数,其名称暗示你只能传递一个网格。这是一个错误,尽管可以理解,但可以通过研究该方法的文档并看到参数的名称是mesh,而参数的预期类型是我们老朋友变换节点来纠正!

注意

严格来说,文档没有错误,因为TransformNode类型。

PlanningScreenGui 组件在其构造函数中已经可以访问 planningScreen 字段中 encounterManager 属性,因此我们可以订阅其 onNewEncounterObservable 以在发生新的 encounter 时得到通知。在观察者函数中,我们从 encounter 本身获取图像 URL,并使用它来创建 Babylon.js GUI 元素,然后将其链接到航线的相关 TransformNode


const encounter = evt.encounter;
let panel = new Rectangle("panel-" + encounter.name);
let image = new Image("image-" + encounter.name, 
    encounter.image);
image.alpha = 0.68;
panel.addControl(image);
panel.thickness = 0;
this.gui.addControl(panel);
this.encounterPanels.push(panel);
panel.linkWithMesh(evt.cargoData);

这就是放置在正确位置并带有正确图像的视觉效果,所以现在,让我们考虑显示 encounter 时还涉及哪些内容。首先,我们希望有一个声音效果播放。这可以通过将 SpaceTruckerPlanningScreen 注册到 onNewEncounterObservable 来完成,如下面的代码所示:


this.encounterManager.onNewEncounterObservable.add(enc => 
        this.soundManager.sound("encounter").play());

虽然我们现在没有使用实际的 encounter 索引,但这种方法允许它在未来轻松扩展——例如,允许单个 encounters 指定它们自己的声音播放。当发生 encounter 时,我们不想图标简单地出现,没有任何喧哗。我们想确保玩家的注意力被吸引到它上面,但只是短暂的一瞬间。实现这一目标的一种方法是将面板最初渲染得比最终大小大得多,然后动画化面板,使其缩小到最终大小和位置。

动画化 encounter 面板

第四章 创建应用程序 中,我们看到了如何静态定义一个 动画,稍后将其作为 AnimationGroup 的一部分针对特定对象进行定位。我们在这里将使用相同的技术来定义涉及缩小 encounter 面板的动画。

重要提示

尽管看起来可能不是这样,几乎任何对象都可以成为 GUI.Image 组件的 scaleXscaleY 属性的目标。

注意,这里涉及两个独立的动画——一个用于 X 轴,另一个用于 Y 轴——因为使用了 AnimationGroupaddTargetedAnimation 方法,并指定了目标 panelpanelShrink 动画,之后动画开始:


let animationGroup = new AnimationGroup("shrinkAnimationGroup-"+ encounter.name, 
    this.scene);
animationGroup.addTargetedAnimation(panelShrinkX, panel);
animationGroup.addTargetedAnimation(panelShrinkY, panel);
animationGroup.start(false, 1.0, 0, 180, true);   

这为我们提供了 encounters 的良好展示,仅剩下我们尚未覆盖的一个用例——重置路线规划屏幕。

清除 encounter 面板

遇到 GUI 元素的列表是为了应对这种场景而收集的,这个列表是 encounterPanels 数组。由于每个 GUI 控件都实现了 dispose 函数,我们可以通过遍历数组并依次调用每个元素的 dispose 方法来重置 encounters UI。为了避免需要预测每个需要这样做的地方,我们可以在最合理的地方添加逻辑——即 onScreenStateChange 观察者函数。每当它执行逻辑以过渡到 ReadyToLaunch 状态时,encounter 面板会清除所有元素,并且所有子元素都会被销毁:


this.encounterPanels.forEach(panel => {
    panel.children.forEach(child => child.dispose());
    panel.dispose();
});

就这些了。

重要提示

当然,这还远不止这些!在遭遇视觉的背景下,还有许多许多可以做到和正在做的事情,但总的来说,所有这些都基于本节和本章中提出的相同概念。如果你还没有这样做,别忘了提醒自己:游戏开发很困难,有很多移动部件!

尽管我们只介绍了如何清除遭遇界面面板,但这种模式完成了创建和销毁的循环。

注册监听 EncounterManager.onNewEncounterObservable 通知组件新的遭遇,而将 SpaceTruckerPlanningScreen.onStateChangeObservable 设置为 ReadyToLaunch 状态则会清除任何现有的状态。

摘要

让我们退一步,回顾一下本章所涵盖的内容。首先,我们走了一条旁路来参观各种 Babylon.js 粒子系统,利用并调整了太阳粒子系统集来满足我们的需求。

我们可以从三个大致的区别类别中观察粒子系统——“经典”、“新浪潮”和“硬核”名称。每个名称都指的是始终可用的基于 CPU 的粒子系统、基于 GPU 的 GPU 粒子系统以及混合的 固体粒子系统SPS)。前两种系统基于 2D 招牌和精灵——这些可以通过精灵表等动画化——而 SPS 使用源网格生成粒子,这些粒子可以进一步分配任何所需的材料(我们将在 第十章通过光照和材料改进环境)。

加载自定义粒子系统集涉及捕获包含每个粒子系统特定参数的序列化 JSON 文件,这些参数适用于适当的结构。这可以通过调用 ParticleHelper.ExportSet(setA, setB,…) 来完成。从 URL 加载保存的 JSON 很容易,但从中加载本地 URL 则要复杂一些。然而,通过回退到 ParticleSystemSet.Parse 函数,我们可以以任何我们想要的方式加载 ParticleSystemSet 的数据!

在对粒子系统的偏离之后,我们检查了 CargoUnit 在其飞行过程中捕获的遥测数据,以及我们如何使用 TransformNodes 来表示空间位置。这证明对于在不编写大量代码的情况下轻松显示视觉效果至关重要,并且为理解遭遇区域的工作原理提供了一个良好的入门途径。

每个遭遇区域可以被视为Space-Truckers世界中一个独特的生物群落或环境。从温暖的内系统到寒冷的外系统,每个区域都有其可能遭遇的一套潜在事件。通过内边界和外边界定义,将这些方便的gameData数值转换为用于创建一组嵌套的环面网格的参数值,涉及一些简单的数学计算。这些网格不进行渲染,但它们的ActionManagers用于注册针对CargoUnitIntersectionEnterIntersectionExit触发器。当它穿越系统时,SpaceTruckerEncounterManager会跟踪玩家的CargoUnit当前正在穿越的区域。

当穿越一个特定区域时,该区域的update方法会在每一帧进行一次加权随机检查,以决定是否应该有一个从区域遭遇表中选择的事件发生。遭遇区域构造函数预先计算表中的每个条目的累积质量分布函数值,使概率归一化,以确保总和为 1。如果遭遇被“掷出”,则掷出的值(介于 0 和 1 之间的数字)被用作函数的输入,该函数返回指向指定事件的索引。SpaceTruckerEncounterManager监听这些遭遇区域事件。

负责聚合遭遇数据与CargoUnit数据,并通知订阅者如何找到生成的遭遇数据,onNewEncounterObservable是组件如PlanningScreenGui更新自己的主要方式,无需在应用程序的组件中散布适当的逻辑。在PlanningScreenGui中,在创建启动屏幕时首先完善的技巧派上用场。在这里,我们可以定义一些动画,这些动画针对遭遇的图像面板,使其在进入时产生缩放效果。

linkWithMesh函数中,并传入从飞行遥测中捕获的TransformNode

与往常一样,本章讨论的代码可在github.com/jelster/space-truckers/tree/ch7找到,尽管在这个历史时刻游戏仍然不完整,但我们可以通过本章的内容指出具体和有价值的进展——永远不要忘记为自己在旅程中取得的进步给予肯定!关于代码、书籍或应用的讨论板github.com/jelster/space-truckers/discussions是发布有关代码、书籍或应用的提问的好地方。想要贡献?导航到github.com/jelster/space-truckers/issues并浏览吸引你的开放问题,或者创建一个新的问题来讨论你的增强、错误或新功能。在下一章中,我们将充分利用路线和生成的遭遇来构建驾驶迷你游戏。在这个过程中,我们将学习如何将摄像机视角切换到第一人称视图,将贴图应用到网格上,以及更多!

注意

有一个专门针对首次贡献者以及缺乏 Babylon.js 和/或 Space-Truckers 经验或熟悉度的人的问题类别——它被称为良好入门问题。想要让麦格维感到自豪并修复其他人无法修复的问题吗?查看需要帮助标签!

扩展主题

总是有比时间和空间更多内容和建议,因此这里有一些方法可以帮助你利用本章的内容进一步挑战自己。在进入下一章或之后的任何时候,将这些视为你澄清和融合在这里学到的教训的经验发射台:

  • 在路线规划阶段的飞行阶段,当货物与某物碰撞时,基于火花粒子样本的新粒子系统将被触发。

  • Space-Truckers 的世界丰富多彩,游戏可以展示更多的这种丰富性。使用动画粒子系统让这些区域生动起来:

    • 轨道制造可能看起来像是一系列闪烁的灯光,这些灯光来自机动推进器和焊接火炬,围绕着阴影中的块状结构聚集。

    • 在太空建设和太空高速公路区域内的交通也可以用闪烁的灯光群来近似表示,这些灯光在周围快速移动

  • 添加从除gameData文件之外的外部来源加载遭遇列表的功能。源可以是相对路径或远程路径,对于每个区域,将重新计算新的列表的 CMDFs。

  • 制作一个增强的随机数生成器,该生成器将权衡或重新掷骰随机值,以避免在 Y 掷中超过 X 次返回相同的值。

  • 计算机生成的随机数往往会出现不切实际的聚集和簇状分布——小行星就是一个很好的例子。尽管如此,还有其他方法可以权衡和生成随机数序列。偷偷前往 GitHub 上 Space-Truckers 源代码的ch10分支,看看randomGenerator模块是如何实现getGaussianRandom的,以其中一个例子为例。

第八章:构建驾驶游戏

虽然可能难以相信,但我们已经正式过了 halfway point – 虽然终点仍然遥不可及,但我们已经取得了如此大的进展,以至于很难看到我们的起点。在前六章中,我们构建了大量功能,涵盖了几乎令人叹为观止的主题多样性。以下图显示了我们的起点和现在:

![图 8.1 – 起始与现在的对比。一组显示我们进展的屏幕截图img/Figure_8.01_B17266.jpg

图 8.1 – 起始与现在的对比。一组显示我们进展的屏幕截图

从设置基本网络应用程序到实现随机遭遇,我们已经投入了大量代码到这个阶段,但我们不会停止,甚至不会减速!将这本书做到这个程度显示了可嘉的坚持和决心 – 本章将是所有这些努力的回报。游戏开发中更令人愉快的一个方面也是更明显的一个方面 – 那就是开始着手核心游戏玩法和逻辑代码。不幸的是,正如有软件开发和发布经验的人所证实的那样,构建和交付软件的所有其他活动往往占据了可用项目时间的很大一部分。

在本章中,我们将构建 Space-Truckers 的驾驶阶段。除了我们之前学到的某些技术外,我们还将为老工具箱引入一些新工具。我们将通过在场景中添加第二个相机来提升一个档次,该相机将渲染图形用户界面GUI)。我们将根据前一阶段的模拟路线生成路线,并允许玩家沿着该路线驾驶他们的卡车,避开障碍物(如果他们能的话)。我们的场景将使用物理引擎,就像前一阶段一样,但我们将利用物理引擎的能力来模拟碰撞、摩擦等结果。我们将介绍一些内容,但将更详细的检查推迟到未来的章节 – 在这种情况下,相关的章节和部分将被链接,以便于参考。

所有这些令人兴奋的主题将有望为我们在前面所面临的更平凡但同样重要的任务——构建必要的逻辑——提供充分的补偿。在本章结束时,我们将拥有一个可玩的游戏,这将为我们下一章的学习打下基础,我们将继续完成整个游戏的生命周期,同时学习如何计算和显示得分结果。

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

  • 驾驶阶段的原型设计

  • 与应用程序集成

  • 添加遭遇

  • 制作迷你地图

技术要求

从技术角度来看,本章没有要求任何之前章节中未列出的内容,但有一些技术领域,在阅读本章时拥有实际知识可能是有用的:

驾驶阶段的原型设计

有很多工作要做,所以让我们直接进入正题。由于驾驶阶段的设计方式,玩家必须沿着在之前游戏阶段由玩家预先确定的路线驾驶他们的卡车。整体计划的路线性质决定了驾驶路线的相似整体特征。诸如总通行时间、距离和速度等因素都属于这种特征。其他因素,如路径上的随机遭遇,则更局限于路径的特定部分。每个遭遇的行为是可变的,但所有遭遇都将采取一种迫使玩家在驾驶太空卡车时做出选择以避免/获得碰撞的一般形式。捕捉两个阶段之间的相关性是一个重要的设计规范,将非常有用——以下是为此目的创建的 Space-Trucker 问题中列出的内容:

图 8.2 – 路线规划与驾驶阶段变量的比较。来源:https://github.com/jelster/space-truckers/issues/84

图 8.2 – 路线规划与驾驶阶段变量的比较。来源:github.com/jelster/space-truckers/issues/84

一些属性在相位之间存在直接的 1:1 相关性,例如总通行时间和行驶距离。其他属性则用作比例或其他间接影响因素,例如点速度影响路线的直径。所有这些内容将在本章的后续部分变得非常有用,但就目前而言,我们将把注意力转向构建一个演示驾驶阶段核心原理的游乐场。

游乐场概述

在软件中进行原型设计,就是将特定问题或兴趣领域简化到其本质。这迫使我们提出问题——为了评估特定方法的可行性,需要的最小特征、属性、功能等集合是什么?在我们的驾驶阶段原型中,我们不需要通过规划阶段来达成目标——我们只需要能够处理该阶段生成的路线数据。专注于这个问题,将我们的路线数据连接到驾驶阶段的问题并不是我们现在试图解决的问题(尽管我们当然可以通过以促进构建该逻辑的方式结构化我们的代码来为未来的自己提供帮助!)。这节省了可以用于其他地方的思维带宽和能量,这也是我们将开始的地方。

重要提示

playground.babylonjs.com/#WU7235#49 的游乐场是本章该部分的参考。

我们需要物理引擎正常工作,以便我们可以测试卡车、障碍物和速度之间的交互和关系。我们需要确定加载第一个 3D 资产模型(半挂卡车)的正确缩放、方向和导入设置。最后,我们需要弄清楚如何在玩家面前的雷达 GUI 上绘制即将到来的障碍物。这似乎是一项相当大的任务,但多亏了 Babylon.js 内置的功能,其复杂性远低于表面看起来那么多。以下截图展示了这些元素如何在游乐场演示中结合在一起:

图 8.3 – 空间卡车手驾驶阶段游乐场,见 https://playground.babylonjs.com/#WU7235#49

图 8.3 – 空间卡车手驾驶阶段游乐场,见 playground.babylonjs.com/#WU7235#49

在视口的中心是我们的游戏主角,即同名空间卡车手。空间道路在他们面前延伸,散布着填充遭遇地点的无纹理方块。在屏幕的左下角,雷达显示器以圆形扫过,揭示即将到来的障碍物作为亮点。摄像机与卡车相连,因此玩家的视角始终在卡车后面和略高位置——这是经典的第三人称视角。控制很简单——WS 在卡车的前进方向上加速和减速,而 AD 分别向左和向右加速。垂直加速由 上箭头下箭头 键管理,旋转由 右箭头左箭头 管理;通过按 Delete 键重置演示。尽量快速到达路径的尽头!

让我们转到查看演示代码以及演示的结构。立即,我们可以看到一些相似之处,但也有一些与我们的先前 PlayGround 演示结构不同的地方。在最顶部是各种资产 URL 和 gameData 对象,然后我们到达最引人注目的差异:async drive(scene) 函数。

这,正如 async 前缀所暗示的,是在函数体中的表达式中一个 await 语句,二是提供一个容器,用于封装演示中使用的所有 var-ious 对象和值。

注意

本书编辑为让你忍受刚才那个糟糕的双关语表示歉意。

狂野的双关语已经释放,我们将继续查看 drive 函数上方的 PG 的前几行。为了加载我们的路线数据,我们将选择将 jQuery.getJSON 的调用包装在一个承诺中,该承诺解析为路线路径点的数组:


var scriptLoaded = new Promise(
    (resolve, reject) =>
        $.getJSON(routeDataURL)
            .done(d => resolve(d))
);

这要求我们将 createScene 方法指定为 async,这样我们就可以在完成驾驶阶段初始化逻辑后,编写一个简单的 harness 来实例化并返回 PlayGround 的 Scene:


var createScene = async function () {
    var routeJSON = await scriptLoaded;
    var scene = new BABYLON.Scene(engine);
    const run = await drive(scene, routeJSON);
    run();
    return scene;
};

drive 函数负责创建和/或加载任何可能需要一些时间才能完成的资产或资源,因此它也被标记为 async。这个函数中包含大量的代码,为了使其更容易处理,逻辑被拆分成了几个辅助方法。在这些方法之前,构建或定义了基本场景和环境设置的逻辑。这些是可能被任何或所有(可能是异步的)辅助函数需要的元素,包括以正确的顺序调用这些辅助函数。一旦这些任务完成,就返回 run 函数:


await loadAssets();
initializeDrivingPhase();
initializeGui();
return run;

我们将在本章的 制作迷你地图 部分介绍 initializeGui 方法,在建立更多上下文之后。在 drive 函数中,可能是我们希望在 PlayGround 中证明的最重要的辅助函数是 calculateRouteParameters(routeData) 方法。这是驾驶阶段世界创建的工作马,可能对游戏玩法如何演变有最大的影响,因为它决定了玩家驾驶的路线属性。

生成驾驶路径

第七章 处理路线数据 中,我们设置 cargoUnit 记录 routeData:时间、位置、速度、旋转和重力都在渲染的每几个帧中捕获到数据点集合中(包括遭遇,我们将在 添加遭遇 部分中讨论)。遥测数据是一个充满创意和有趣想法的深井(参见 扩展主题),但就目前而言,我们将仅使用 PlayGround 概述 部分中描述的位置、速度和重力路线值来生成路线路径。

函数的开始部分从gameData中获取routeDataScalingFactor;尽管目前设置为Vector3值,而不是普通的 JavaScript 对象。

重要提示

采取这样的主动措施来减少快速迭代的摩擦,对于建立势头至关重要!

完成这些后,我们使用来自遥测数据的定位向量来构建一个新的Path3D实例:


let path3d = new Path3D(pathPoints.map(p => p.position),
  new Vector3(0, 1, 0), false, false);
let curve = path3d.getCurve();

来自 Babylon.js 文档(doc.babylonjs.com/divingDeeper/mesh/path3D)

“一个Path3D是由曲线上的点的位置向量序列创建的数学对象。”

Error! Hyperlink reference not valid.

换句话说,一个Path3D代表了一组有序的坐标点,并具有一些有趣和有用的特性。

注意

称其为“数学对象”的原因是它不是场景的成员,也不参与渲染。这听起来也比称之为“非渲染的抽象几何数据结构”酷多了。

getCurve()方法是一个实用方法,它会返回定义路径的点序列,但在Path3D中还有更多有用的价值点,我们很快就会探索。不过,首先,我们希望显示玩家在规划阶段所采取的具体路径,作为穿过空间道路中间的直线。这很简单——我们使用曲线数组在调用MeshBuilder.CreateLines时,这就是全部!有关更多信息,请参阅doc.babylonjs.com/divingDeeper/mesh/creation/param/lines。在那之后,我们开始构建空间道路的几何形状,这是事情开始变得有趣的地方。

构成我们空间道路基础的几何形状是带状物——一系列一个或多个路径,每个路径至少有两个Vector3点。提供的路径顺序与路径本身结合使用,以产生具有巨大灵活性的几何形状,尽管这可能很有趣,但尝试复制在带状物文档中已经创建的优秀示例可能会适得其反。从那些文档中,这个思想实验很好地解释了我们目前正在研究的概念:

“想象一下在现实世界中一条长而窄的带状物,其长度上有一条线。关闭路径形成一个带状物的环,而关闭数组则会形成一个管状物。”

关闭数组看起来是我们想要的选项,而不是关闭路径本身,因为我们希望我们的道路是封闭的,但又不像是甜甜圈或环。这让我们面临一些选择,关于我们希望如何实现这一点,但只有在确立了通过原型化实现它的价值之后,这在这个情况下才成为我们选择实现路径的无限循环论证的链接。

在原型化路径创建(或软件中的任何原型化过程)时,在某个阶段你会意识到需要从只是拼凑一些东西来测试其是否可行,并考虑到构建一个更稳健的产品的需求。Playground 片段#WU7235#11playground.babylonjs.com/#WU7235#11)展示了大约从第 168 行开始的原型化逻辑可能的样子(为了清晰起见,已移除注释):


let pathA = [];
let pathB = [];
let pathC = [];
let pathD = [];
for (let i = 0; i < pathPoints.length; i++) {
    const { position, gravity, velocity } = pathPoints[i];
    let p = position;
    let speed = velocity.length();
    let pA = new Vector3(p.x+speed, p.y-speed, p.z+speed);
    let pB = new Vector3(p.x-speed, p.y-speed, p.z-speed);
    let pC = pB.clone().addInPlaceFromFloats(0, speed * 2,
      0);
    let pD = pA.clone().addInPlaceFromFloats(0, speed * 2,
      0);
    pathA.push(pA);
    pathB.push(pB);
    pathC.push(pC);
    pathD.push(pD);
}

这是一个路径几何的方案,其形式为四边形的方框(两端是开放的)。前面的代码使用四个独立的点数组 – 每个角落一个 – 来捕捉路径,它在沿着路线的每个点循环时记录路径。这看起来是这样的:

图 8.4 – 原型路径几何硬编码为制作一个四边形的方框,两端开口。使用了四个路径。简单而有效,但极其有限(https://playground.babylonjs.com/#WU7235#11)

图 8.4 – 原型路径几何硬编码为制作一个四边形的方框,两端开口。使用了四个路径。简单而有效,但极其有限 (playground.babylonjs.com/#WU7235#11)

任务完成!我们在这里就结束了,对吧?错了。这只是一个开始!庆祝成就当然可以,但最好是将庆祝的规模与最终目标所取得的成就相匹配。一个方框形状可以证明我们可以从实际路线数据中创建一个可玩路径演示,但这看起来既不有趣也不吸引人。为了将其提升到一个能够令人惊讶和愉悦用户的地方,我们需要让它更加球形而不是方形。为了做到这一点,我们需要添加更多的路径段,而我们的原型就在这里达到了极限。

参考前面的代码列表,带状路径的每条路径都已预定义,形式为pathApathBpathCpathD数组。如果我们想添加更多段,我们需要手动添加额外的路径数组,以及适当的逻辑,以正确定位彼此不是 90 度直角的路径段——这使得我们的当前方法变得更加困难。有一种特定的思维方式更喜欢直接面对这类问题,使用蛮力。他们可能会添加pathEpathFpathG数组,并基于硬编码的数字预先计算路径相对于彼此的偏移量,尘埃落定后,出来的结果可能工作得很好……直到需要再次更改段数。或者更糟糕的是,需要根据例如设备性能特性动态地设置路径数量。这就是为什么有必要想出一个更好的前进方法。

让我们回到我们最初开始的原始游乐场——NUM_SEGMENTS常量。接下来,我们需要实例化新的路径数组来保存每条路径。我们通过一个简单的循环来完成这个操作:


const NUM_SEGMENTS = 24;
let paths = [];
for (let i = 0; i < NUM_SEGMENTS; i++) {
    paths.push([]);
}

太好了,我们准备好了路径数组的数组。现在,是时候填充这些路径了,所以我们设置了一个外层循环遍历routePath,包含一个内层循环遍历每个路径数组。但我们如何确定每条路径的每个点的位置?仅仅使用像原型中那样对每个点位置使用简单的常数偏移量是不够的;每个路径段的点将彼此有不同的偏移值。在下面的图中,环形或环形形状是一个单独的横截面段,所有点都位于同一平面(数学家称之为点的仿射集):

图 8.5 – 创建路线几何的点从中心点开始,该点沿直径顺时针移动,为每个离散段添加路径点

图 8.5 – 创建路线几何的点从中心点开始,该点沿直径顺时针移动,为每个离散段添加路径点

从当前路线位置开始,并以此作为中心点。现在,专注于通过routeData执行最外层循环的单一实例,我们知道我们需要创建与所需段数相等的点。我们还知道这些段应该均匀且连续地分布在假设圆的直径周围。

注意

我们使用圆而不是球体的原因是,相对于给定的路线点,每个路径段的Z轴值对于该点的每个路径段始终相同。这相当自相矛盾,因为这也是定义圆的一种相当曲折的方式!

将这些事实放在一起,并结合我们已知的关于圆和三角函数的知识,我们就有了一种实现我们想要的方法。唯一剩下的障碍是:我们如何改变正在计算的单独路径上的位置偏移量?幸运的是,这个问题并没有看起来那么大。

让我们再次提醒自己关于圆和三角函数的事实。正弦和余弦函数各自接受一个输入角度(除非另有说明,否则以弧度为单位),并输出一个介于-1 和 1 之间的值,分别对应于角度相关的X-和Y-轴值。一个完整的圆包含两倍的 Pi(3.14159…)弧度,或大约 6.28 弧度。如果我们把线段的数量除以 6.28 弧度,我们就会得到单个线段所跨越的弧,但如果我们把线段的数量除以当前迭代的线段的零基索引,那么我们就会得到更有用的事情——我们当前线段的位置在 0..1 之间。换句话说,一个百分比,或比率。通过将这个比率乘以两倍的 Pi 值,我们得到…线段的位置,以弧度为单位!剩下的只是将结果按代表所需半径(或直径,对于X-轴)的值进行缩放,并将其添加到路径集合中:


for (let i = 0; i < pathPoints.length; i++) {
    let { position, velocity } = pathPoints[i];
    const last = position;
    for (let pathIdx = 0; pathIdx < NUM_SEGMENTS;
      pathIdx++) {
        let radiix = (pathIdx / NUM_SEGMENTS) *
          Scalar.TwoPi;
        let speed = velocity.length();
        let path = paths[pathIdx];
        let pathPoint = last.clone().addInPlaceFromFloats(
            Math.sin(radiix) * speed * 2,
            Math.cos(radiix) * speed, 0);
        path.push(pathPoint);
    }
}

在前面的代码列表中,从速度向量到确定空间道路的大小。我们必须在修改它之前克隆最后一个点;否则,我们最终会破坏应用程序其余部分所需的数据。通过将NUM_SEGMENTS的值设置为4并逐步以递增的数字运行沙盒,我们可以很容易地看到更新的逻辑现在可以处理任意数量的线段——这比我们的第一代原型有了巨大的改进!当我们准备从初始化驾驶阶段部分开始那个过程时,这段代码将准备好与应用程序集成。但在那之前,我们还需要在其他领域证明一些事情。loadAssets函数是我们列表中的下一个。

异步加载资产

在这个沙盒中,我们将作为loadAssets函数的一部分异步加载两样东西——半挂车模型和雷达程序纹理资产。我们需要确保在继续之前所有异步函数调用都已经完成,通过返回一个只有在所有其构成承诺都这样做之后才会解决的承诺来实现。这就是在loadAssets()中看起来像什么:


return Promise.all([nodeMatProm, truckLoadProm])
          .then(v => console.log('finished loading
            assets'));

nodeMatProm 是使用在 Babylon.js 中广泛使用的一种模式创建的,我们最近在上一章关于加载 ParticleSystemSet JSON 的讨论中使用过。但在这个 Playground 中,我们不是直接加载 JSON,而是从 Babylon.js Snippet Server 加载数据。具体来说,我们正在加载来自 节点材质编辑器NME)的片段,然后我们将使用它来创建显示在 GUI 上的雷达程序纹理。关于这些元素的更多细节将留待 第十一章Shader 的表面之下 中讨论。


const nodeMatProm = NodeMaterial.ParseFromSnippetAsync
  (radarNodeMatSnippet, scene)
      .then(nodeMat => {
         radarTexture = nodeMat.createProceduralTexture(
         radarTextureResolution, scene);
      });

虽然可能很明显,radarTexture 是一个包含 radarTextureResolution 值的变量。创建一个“简单”的游戏原型的一个困难之处在于,即使是简单的东西也需要创建和管理相当数量的配置数据。gameData 结构的作用是集中和整合对这些类型值的访问;当我们想在函数中使用一个或多个这些值时,我们可以使用 JavaScript 的 解构 功能来简化代码并使其更易于阅读:


const { 
        truckModelName, 
        truckModelScaling, 
        radarTextureResolution } = gameData;

正如我们在前面的代码块中看到的,radarTextureResolution 用于确定程序纹理的渲染高度和宽度(以像素为单位),而我们将很快看到 truckModelNametruckModelScaling 的用途。SceneLoader.ImportMeshAsync 方法(新引入 v5!)接受一个可选的模型名称列表,以及包含要加载的网格的适当文件的路径和文件名(例如,.glb.gltf.obj 等),以及当前场景。返回的承诺解析为一个包含加载文件 meshesparticleSystemsskeletonsanimationGroups 的对象,尽管在这个场景中我们只将使用网格集合。

注意

你可以在 doc.babylonjs.com/divingDeeper/importers/loadingFileTypes#sceneloaderimportmesh 了解更多关于 SceneLoader 及其相关功能的信息。

一旦我们加载了半挂车的模型文件,我们还需要做一些额外的工作,才能开始使用加载的资产。以 GLTF 或 GLB 格式保存的模型在导入 Babylon.js 时会带有一些额外的属性,这些属性可能会妨碍我们,所以让我们简化并设置 truckModel 以适应游戏世界:


const truckLoadProm = SceneLoader.ImportMeshAsync
  (truckModelName, truckModelURL, "", scene)
    .then((result) => {
        let { meshes } = result;
        let m = meshes[1];
        truckModel = m;
        truckModel.setParent(null);
        meshes[0].dispose();
        truckModel.layerMask = SCENE_MASK;
        truckModel.rotation = Vector3.Zero();
        truckModel.position = Vector3.Zero();
        truckModel.scaling.setAll(truckModelScaling);
        truckModel.bakeCurrentTransformIntoVertices();
        m.refreshBoundingInfo();
    }).catch(msg => console.log(msg));

我们处理管道的前几行对结果结构中的变量进行了一些方便的设置,但然后在处理 meshes 数组中的第一个网格之前,我们将 truckModel 的父级设置为 null,这是怎么回事,SCENE_MASK 又是什么意思?

注意

更多关于层掩码及其如何工作的信息,请参阅文档:doc.babylonjs.com/divingDeeper/cameras/layerMasksAndMultiCam

第二个问题的答案是,简而言之,相机可以分配一个特定的编号,这个编号只允许具有兼容的 layerMask 的网格被该相机渲染。我们使用 layerMask 属性来隐藏非 GUI 网格从主场景相机中,例如。第一个问题的答案在于从 GLB 或 GLTF 文件加载资产的具体细节。当 Babylon.js 读取文件时,在模型层次结构的根位置放置了一个名为 __root__ 的不可见变换节点。尽管在简单场景中不会引起任何问题,但在处理物理、父节点、碰撞和变换时,它成为一个主要的障碍。以下截图说明了在 场景检查器 窗口中看起来是什么样子:

图 8.6 – Alien.gltf 模型。场景检查器窗口显示了 __ 根 __ 变换节点。来源:https://playground.babylonjs.com/#8IMNBM#1

图 8.6 – Alien.gltf 模型。场景检查器窗口显示了 __ 根 __ 变换节点。来源:playground.babylonjs.com/#8IMNBM#1

我们感兴趣的是与 Alien 几何体一起工作,但由于它被连接到 __root__ 节点,对 Alien 的位置、旋转或缩放的任何更改都是在相对于该根节点的坐标系中评估的,这会导致不希望的和不可预测的结果。这个问题的解决方案很简单,回答了我们之前关于 loadAssets 代码中发生了什么的问题——取消所需网格的父节点并丢弃根节点。一旦完成,我们卡车加载方法中的其余代码都是为模型进行的家务设置——有一些重要的考虑事项需要记住:

  • 操作顺序很重要,但不是你想象中的那种方式。在给定帧中对 TransformNodeMesh 是其子类)的更改按照固定的顺序 变换、旋转、缩放TRS)应用。

  • 使用 setParent(null) 而不是将 mesh.parent 设置为 null 的替代方案。setParent 函数保留位置和旋转值,而将父节点设置为 null 则不会。这会导致从网格中移除任何根变换,这就是为什么我们需要重置位置和旋转向量。

  • 一旦变换被清除并且缩放被设置为适合世界的值,网格几何体将需要生成新的边界信息。否则,碰撞将无法正常工作。这个问题的解决方案是在调用 mesh.refreshBoundingInfo() 之前调用 mesh.bakeCurrentTransformIntoVertices() 的两步过程。

重要提示

通常,不建议在存在更好的选项(如 parentingpivotPoints)时调用 bakeCurrentTransformIntoVertices。在这种情况下,我们需要执行此步骤,因为我们已经从根节点移除了父级。有关此主题的更多信息和建议,请参阅doc.babylonjs.com/divingDeeper/mesh/transforms/center_origin/bakingTransforms

如前所述,调用 Promise.all 与未解决的承诺的结果是返回的 loadAssets,使我们回到了这次讨论的起点!初始化主要完成——或者至少是最耗时的部分完成——现在随着半挂车模型的可用性,initializeDrivingPhase 函数已被调用以设置场景的其他元素。此函数设置摄像机,从 routePaths 创建地面带状网格,设置物理属性,等等。

初始化驾驶阶段场景

如本章引言所述,玩家的视点是第三人称视角,摄像机位于半挂车后面,并从上方俯视。随着卡车的移动(平移)或旋转(嗯,旋转),摄像机从其偏移位置模仿每一个动作。这是通过将现实世界的类比很好地匹配到软件中的一种情况,即 cameraDolly

摄像车通常是一种在电影行业中使用的工程式手推车,它允许操作摄像机的摄影师在多个维度上平滑移动并捕捉画面。我们的摄像机车不使用轨道,但它通过随卡车移动来保持相同的面向前方方向,无论卡车的世界空间方向如何。这可以通过几个步骤完成:

  1. 创建一个 TransformNode 作为“摄像机车”:

    var cameraDolly = new TransformNode("dolly", scene);
    
  2. 定义一个 ArcRotateCamera 并设置其基本属性。我们正在从 gameData 结构中修补属性值以减少代码量:

    for (var k in followCamSetup) {
        followCamera[k] = followCamSetup[k];
    }
    
  3. 操作顺序对于这一步和下一步都很重要!首先,将 cameraDolly 父级设置为 truckMesh

  4. 现在,将 followCamera 父级设置为 cameraDolly

            cameraDolly.parent = truckModel;
            followCamera.parent = cameraDolly;
    

initializeDrivingPhase 方法中发生的第一件事是创建摄像机并设置视口。这里简要说明一下这一点。

如果一个 (0,0) 并且大小为 (1,1)。换句话说,默认视口的左上角位于 (0,0),右下角位于 (1,1);整个屏幕都被它覆盖。当场景只有一个摄像机时,这非常理想,但在许多情况下,在场景中某个位置放置一个渲染到整个屏幕较小部分的第二个摄像机是有用的——想想提供迷你地图的策略游戏或具有后视镜显示的赛车游戏。

在大多数情况下,场景中应该只在一个相机中渲染某些元素,但在另一个相机中不渲染,这正是我们与层掩码建立联系的地方。通过设置所有相关相机和网格的layerMask,我们可以根据网格在场景中的作用高效地显示或隐藏几何形状。我们的驾驶屏幕目前有两个独立的层掩码:SCENE_MASKGUI_MASK。巧妙地切换网格的layerMask属性可以允许对相机渲染进行精细控制;如果我们想在某个相机上显示网格,我们可以显式地将它的layerMask设置为SCENE_MASKGUI_MASK(分别为0x000000010x00000002)。如果我们想在两个相机上显示网格,我们可以设置和/或保留默认的层掩码值(0xFFFFFFFF)。现在我们知道了视口的情况,我们可以回到函数代码。

在设置好视口之后,执行之前列出的父级步骤。MeshBuilder.CreateRibbon方法是下一个关注的点,我们将数组或路径数组传递到函数的选项中,并获取我们的路径几何形状,然后对其进行一些属性调整,并分配一个网格材质(目前是这样):


var groundMat = new GridMaterial("roadMat", scene);
var ground = MeshBuilder.CreateRibbon("road", {
    pathArray: route.paths,
    sideOrientation: Mesh.DOUBLESIDE
}, scene);
ground.layerMask = SCENE_MASK;
ground.material = groundMat;
ground.visibility = 0.67;
ground.physicsImpostor = new PhysicsImpostor(ground,
    PhysicsImpostor.MeshImpostor,
    {
        mass: 0,
        restitution: 0.25
    }, scene);

在创建出带状物、分配了材质,并将一个物理模拟器同样创建并分配给地面网格之后,恢复系数属性使得任何撞击墙壁的物体都比之前具有更小的动量反弹。这是新的,但这里使用的模拟器类型(MeshImpostor)也有一些转折(在先前的代码块中突出显示)——与之前我们查看的其他PhysicsImpostor类型(BoxSphere)不同。MeshImpostor之前仅在 CannonJS 物理插件中可用,在那里它仅限于与球体交互。

与使用物理启用对象的几何形状的粗糙近似不同,它使用那个几何形状本身来提供精确的碰撞检测!代价是,随着网格几何结构的复杂度增加,碰撞计算变得更加昂贵。不过,对于我们的需求来说,应该没问题,因为我们不需要我们的障碍物(即,遭遇)与路径交互,只需让卡车具有复杂的碰撞计算需求。在我们完成准备工作并准备好编写运行时逻辑之前,还有一些任务需要完成!

在设置truckModel的物理属性——尽管使用了同样适用但更简单的BoxImpostor——之后,我们在设置OnIntersectionExitTrigger之前在路径上生成一些样本障碍物,该触发器会在卡车退出routePath带状网格的界限时调用killTruckspawnObstacles函数将在添加遭遇部分进行讨论,因此跳过对这个问题的讨论将我们引向了实践中熟悉的设置ground.actionManager的过程,使用适当的触发器(参见 第七章**中定义遭遇区域,处理路线数据*)——另一个足够熟悉以至于可以跳过的部分。现在,我们接近initializeDrivingPhase函数的最后一部分——(重新)设置卡车到其起始位置和状态。

使用我们的样本路线数据,我们可以通过实验确定卡车在世界空间中的起始坐标,其初始旋转以及其他类似的值。我们会通过试错法迭代地细化我们的值,直到结果令人满意,但那会满足我们的要求吗?不。

注意

如果你看到有人以这种方式提出问题,答案几乎总是“不。”这是本章中那种修辞写作的第二种情况。你能找到第三种吗?

那种整个试错的方法不会“满足我们的要求”,不,谢谢先生!我们可以通过回忆我们已经确切地知道卡车应该从哪里开始,它应该指向哪里,以及它应该以多快的速度移动,这些信息都包含在我们的朋友route.path3d中,来极大地简化这个过程。在之前关于Path3D的讨论中已经提到,它是一个数学结构,它提供的两个更有用的函数getPointAtgetTangentAt被用来帮助我们定位卡车,但我们并没有深入探讨为什么它们是有用的。

考虑一个由几个点组成的任意长度的路径。路径上的每个点都有一组描述位置的向量(当然是!),Path3D实例,这使得处理起来变得容易。

如果我们将点的位置在路径点集合中的位置(即它在数组中占据的索引)视为索引与元素总数的比率,那么我们就可以很容易地想象这个比率是一个百分比,或者一个介于 0 和 1 之间的数(包括两者)。Path3D模块都接受一个表示路径上百分比的数字(介于 0 和 1 之间)来进行操作,并包括相关的getNormalAtgetBinormalAtgetDistanceAt函数。

注意

还有更多的插值函数可以探索!有关完整列表,请参阅doc.babylonjs.com/divingDeeper/mesh/path3D#interpolatio

这很有用,因为你不需要知道路径的长度或其中有多少个点,就能获得有用的信息。在resetTruck函数中,我们获取路线中第一个点的位置和切线——路径的起点——然后相应地设置卡车的属性:


const curve = route.path3d.getPointAt(0);
const curveTan = route.path3d.getTangentAt(0);
truckModel.position.copyFrom(curve);
truckModel.rotationQuaternion =
  Quaternion.FromLookDirectionRH(curveTan, truckModel.up);
truckModel.physicsImpostor.setAngularVelocity(currAngVel);
truckModel.physicsImpostor.setLinearVelocity(currVelocity);

由于物理引擎设置并使用rotationQuaternion属性,我们无法直接使用getTangentAt(0)提供的向量——我们需要使用FromLookDirectionRH方法将其转换为四元数。此函数接受两个向量作为参数:第一个,表示所需的前进方向的向量,然后是另一个表示正交的向量(例如,沿所有轴垂直),返回值是一个表示输入向量的四元数。在设置卡车的位置和旋转后,有必要重置卡车的物理值,因为从物理引擎的角度来看,移动和旋转的效果需要被考虑。因此,reset方法是一个确定性函数——每次调用时对场景状态的影响总是相同的。这使得它在初始化后立即使用以及玩家选择这样做时都非常有用。我们在这个 Playground 的更新方法中监听那个玩家输入。

运行更新循环

到目前为止讨论的大多数代码都与当前上下文直接相关。这正是 Babylon.js 及其工具的伟大之处——许多常见任务只需几行代码即可完成。update方法是一个很好的例子,但它也是 Playground 中需要完全更改代码以与应用程序集成的少数几个地方之一,这仅仅是因为应用程序比 Playground 的范围更广(有关更多信息,请参阅下一节,与应用程序集成)。因此,我们不会深入研究函数的具体细节,而是专注于卡车是如何通过其中的逻辑来控制的机制。

卡车可以在三个平移轴(前进/后退、左/右、上/下)和一个旋转轴(偏航轴)上被控制,这似乎需要处理运动的总共八种不同的逻辑。然而,由于一对动作(例如,左和右)只是彼此的相反值,我们只需要考虑四种——这是一个很好的复杂性降低。在每个帧中,delta 帧时间变量被用来将truckAccelerationtruckTurnSpeedRadians缩放到正确的值;currVelocitycurrAngVel计数变量跟踪累积的变化,然后在更新过程的最后将这些变化应用到物理模型的线性和角速度上。这就像我们过去所做的那样,但正在使用一些我们尚未见过的数学工具,值得我们仔细研究。

改变前进或后退的平移速度很简单——只需获取卡车网格的当前前进向量,将其乘以currAccel,然后将其加到currVelocity计数器上;后退向量由前进向量的相反值组成:


if (keyMap['KeyW']) {
    currVelocity.addInPlace(currDir.scale(currAccel));
}
else if (keyMap['KeyS']) {
    currVelocity.addInPlace(currDir.scale(currAccel)
        .negateInPlace());
}

所有的Vector3数学方法都有各种风味,允许开发者控制操作是否应该分配内存或重用现有对象。在这种情况下,我们使用addInPlace函数来避免创建新的向量对象,而使用currDir.scale(currAccel)函数调用创建一个新的Vector3,以避免破坏卡车网格的前进向量——这是引擎用于正确渲染所依赖的值。

重要提示

知道何时以及如何进行内存分配和释放对于渲染流畅的场景至关重要。参见第十三章将应用程序转换为 PWA,获取更多信息和建议。

回到我们卡车的控制逻辑,数学技巧在于我们如何确定应用剩余的平移和旋转力的方向。将卡车向左或向右移动是通过取卡车的前进向量和卡车向上向量的叉积来完成的——结果是指向左或右方向的向量(使用negateInPlace的相同技巧可以从相同的输入中得到相反的一侧):


let left = Vector3.Cross(currDir, truckModel.up);
currVelocity.addInPlace(left.scale(currAccel / 2));

允许玩家以与其他方向相同的速度进行侧滑可能会让卡车失去控制,所以我们将其值减半,以帮助玩家控制速度。在将累积的速度变化整合并重置累积计数器后,相应的线性和角物理属性被设置,同时设置一个角“阻尼”机制以帮助缓解控制:


linVel.addInPlace(currVelocity);
truckModel.physicsImpostor.setLinearVelocity(linVel);
angVel.addInPlace(currAngVel);
currVelocity.setAll(0);
currAngVel.setAll(0);
// dampen any tendencies to pitch, roll, or yaw from
   physics effects
angVel.scaleInPlace(0.987);
truckModel.physicsImpostor.setAngularVelocity(angVel);

这就是游乐场更新方法的结束,也是我们对驾驶阶段原型的考察的结束。在查看我们希望通过游乐场实现的整体目标后,我们学习了如何将原始路线数据转换为包含路径的分段管道。在异步加载方法中,我们看到了如何导入并准备 GLTF 模型,以便在看到initializeDrivingPhase函数设置路径上的摄像机、物理和障碍物之前使用场景。通过reset方法,我们看到了如何使用Path3D方法正确地定位卡车,无论它在哪里以及处于何种状态。不计入 GUI(我们将在下一章中介绍),我们已经看到了如何实现原型中的每个目标。这是游戏下一步进展的坚实基础,即集成我们的游乐场到游戏其余部分的过程,这是一个不那么有趣但最终更有回报的过程。

集成到应用程序中

通过构建游乐场驾驶演示,我们揭示了用于应用程序代码的技术和基本设计方法。我们的代码结构使得我们能够简单地将关键功能部分直接提升并转移到应用程序代码库中,但前提是我们必须先进行修改以做好准备。

除了游乐场逻辑之外,SpaceTruckerApplication中还有各种钩子需要添加或修改,以便正确地执行驾驶阶段,其中一些包括能够在不经过路线规划的情况下加载到驾驶游戏中。我们的基本输入控制需要适应 Space-Truckers 的输入系统,以及向输入系统添加新功能的需求。所有这些工作都始于对游乐场代码的解构和引入。

将游乐场拆分

spaceTruckerDrivingScreen是驾驶阶段主要逻辑所在的地方,类似于我们将路线规划模块放入/src/route-planning子目录中,我们将驾驶阶段代码和数据放入一个/src/driving文件夹中。在该文件夹内,同样地,就像route-planning文件夹一样,有一个gameData.js文件,我们将放置同名游乐场对象。从游乐场添加到gameData对象的新内容是environmentConfig部分;这些数据包含有关环境纹理 URL 和其他部署时间特定信息的部分。

注意

我们将使用遭遇系统(参见本章后面的添加遭遇部分)来填充路径上的障碍物,这样就可以从应用程序代码中省略obstacleCount属性。

虽然这与路线规划的代码设计不太一致,但Driving屏幕将环境创建代码分离到它自己的模块environment.js中。仅导出initializeEnvironment函数,这个模块展示了有时并不总是需要创建 JavaScript 类来封装和抽象逻辑——有时候,一个简单的函数就能很好地完成工作:


const initializeEnvironment = (screen) => {
    const { scene } = screen;
    var light = new HemisphericLight("light", new
      Vector3(0, 1, 0), scene);
    light.intensity = 1;
    var skyTexture = new CubeTexture(envTextureUrl, scene);
    skyTexture.coordinatesMode = Texture.SKYBOX_MODE;
    scene.reflectionTexture = skyTexture;
    var skyBox = scene.createDefaultSkybox(skyTexture,
      false, skyBoxSize);
    skyBox.layerMask = SCENE_MASK;
    screen.environment = { skyBox, light, skyTexture };
    return screen.environment;
};
export default initializeEnvironment;

上述代码列表中的代码与我们之前在游乐场中看到的内容没有特别的不同,除了屏幕参数代表的是函数要针对的目标SpaceTruckerDrivingScreen实例。为了确保我们可以访问(并且稍后正确地处置)环境数据,返回给调用者的是一个复合数据结构,包含skyBoxhemisphericLightskyTexture。这与environment.jsdriving-gui.js中的initializeEnvironment方法的initializeGui函数类似。关于这一点的一个小细节是,与initializeEnvironment不同,initializeGui方法被标记为async,但这个模块中正在发生的事情的细节将在下一章中等待

注意

在干预成为必要之前,恶作剧的笑话可以有多糟糕?

我们驾驶阶段的最后一个组件是谦逊的卡车。驾驶阶段的“路线规划”的cargoUnit的对应物,我们的Truck类是从BaseGameObject派生出来的,其中它继承了其基类的updatedispose以及各种其他属性。我们能够直接使用游乐场的loadAssets方法中的大部分代码,并且我们只需要从游乐场的update方法中获取非输入处理代码来与卡车一起使用(屏幕将负责输入动作和处理)。现在我们已经定义了屏幕的逻辑和行为,让我们看看这个逻辑是如何应用到应用程序中的。

转换到驾驶屏幕

在常规游戏过程中,驾驶阶段紧随路线规划阶段之后。当玩家成功将货物单元运送到目的地时,他们会被要求确认路线或重试。在确认的选择上,屏幕提升routeAcceptedObservable以通知感兴趣的各方事件,其中主要订阅者是SpaceTruckerApplicationinitialize方法:


this._routePlanningScene.routeAcceptedObservable.add(() 
  => {
    const routeData = this._routePlanningScene.routePath;
    this.goToDrivingState(routeData);
});

对于其他屏幕(主菜单、启动屏幕和路线规划),我们采取了在SpaceTruckerApplication.initialize方法中创建和加载屏幕的方法。这种方法消除了在之前提到的屏幕之间转换时的延迟,但这种方法不适用于驾驶屏幕。

正如你可能从本章早些时候的讨论中回忆起来的,驾驶屏幕在构造时需要提供 routeData。由于我们尚未能够在玩家创建路线之前确定玩家的路线,因此我们必须将屏幕的构建推迟到那时。我们还应该记住,尽管屏幕可能不会占用渲染时间,但它肯定会消耗内存——在我们过渡到新的游戏状态时,我们明智地处理路线规划屏幕并释放其资源。这是 goToDrivingPhase 函数的工作:


goToDrivingState(routeData) {
    this._engine.displayLoadingUI();
    routeData = routeData ??
      this._routePlanningScene.routePath;
    this._currentScene?.actionProcessor?.detachControl();
    this._engine.loadingUIText = "Loading Driving
      Screen...";
    this._drivingScene = new SpaceTruckerDrivingScreen
      (this._engine, routeData, this.inputManager);     
    this._currentScene = this._drivingScene;
    this._routePlanningScene.dispose();
    this._routePlanningScene = null;
    this.moveNextAppState(AppStates.DRIVING);
    this._currentScene.actionProcessor.attachControl();
}

许多代码是针对我们编写的处理状态转换的方法族的标准代码,例如从 _currentScene 中分离控制并将其附加到新的 _drivingScenemoveNextAppState 的过程,主要区别在于 _routePlanningScene 的处理方式。

屏幕的销毁逻辑相当简单。与场景直接关联的大多数资源将随着场景一起被销毁,但还必须确保 SoundManagerEncounterManager 一起被销毁:


dispose() {
    this.soundManager.dispose();
    this.onStateChangeObservable.clear();
    this.routeAcceptedObservable.clear();
    this.encounterManager.dispose();
    this.scene.dispose();
}

当处理你有所控制的对象时,Observable.clear() 方法很有用,因为它排除了知道或拥有通过 Observable.add 创建的原始订阅的任何需要或引用。驾驶阶段转换的最后一部分是当应用程序启动时直接加载驾驶阶段的快捷方式,使用示例路线数据而不是玩家的模拟路线。这是通过在浏览器的 URL 中包含 testDrive 查询字符串值来完成的;当它存在且玩家跳过启动屏幕时,它将使用示例 JSON 路线数据:


const queryString = window.location.search;
if (queryString.includes("testDrive")) {
    this.goToDrivingState(sampleRoute);
}

这是一种由 Babylon.js 的基本基于网页的特性所启用的巧妙技巧——我们可以轻松地使用熟悉的网页开发技巧和工具来简化测试!能够快速跳转到已填充的、“已知良好”的驾驶阶段,使我们能够快速添加和测试应用程序的各种代码片段,这导致我们关注游乐场和我们的应用程序之间的主要差异区域——Truck 组件如何更新输入。

卡车更新和输入控制

立即,有一个需要解决的问题,那就是处理用户输入的方面。我们的游乐场使用了一个非常简单的输入方案,这需要重构以使用 SpaceTruckerInputProcessor(参见第五章添加场景和输入处理)。由于实际的每帧更新逻辑被委派给 Truck 组件(参见 Splitting Up the Playground 部分),SpaceTruckerDrivingScreenupdate 方法变得非常简单:


update(deltaTime) {
        const dT = deltaTime ?? 
          (this.scene.getEngine().getDeltaTime() / 1000);
        this.actionProcessor?.update();
        if (this.isLoaded) {
            this.truck.update(dT);
        }
    }

isLoaded 标志用于帮助防止在异步初始化逻辑执行期间/时处理不必要的更新。必须在调用卡车的更新方法之前更新输入,以确保已读取并设置了最新值。查看驾驶阶段的控制方案,很明显它与路线规划阶段的控制方案之间存在差异。应用程序需要一种方法来指定仅适用于当前活动屏幕的新或修改后的控制图方案。

补丁输入图

原始的 inputActionMap 定义了与路线规划屏幕和主菜单相关的动作集合,但我们需要支持一些在映射文件中不存在的额外动作。我们还需要重新定义用于在路线规划期间控制摄像头的特定输入。合并这些更改,我们有一个“补丁”可以应用到 inputActionMap 上:


const inputMapPatches = {
    w: "MOVE_IN", W: "MOVE_IN",
    s: "MOVE_OUT", S: "MOVE_OUT",
    ArrowUp: 'MOVE_UP',
    ArrowDown: 'MOVE_DOWN',
    ArrowLeft: 'ROTATE_LEFT',
    ArrowRight: 'ROTATE_RIGHT'
};
SpaceTruckerInputManager.patchControlMap(inputMapPatches);

patchControlMap 函数是 SpaceTruckerInputManager 类的一个静态方法。它有一个相应的 unPatchControlMap 函数,该函数将给定的输入图补丁还原到之前的值:


static patchControlMap(newMaps) {
    tempControlsMap = Object.assign({}, controlsMap);
    Object.assign(controlsMap, newMaps);
}
static unPatchControlMap() {
    controlsMap = tempControlsMap;
    tempControlsMap = {};
}

Object.assign 的两种不同用法值得关注。第一种用法使用一个新空对象 ({}) 来创建原始 controlsMap 的副本或克隆,而第二种用法则是将 newMaps 中的属性复制到现有的 controlsMap 中。这会导致覆盖任何现有的属性,并从输入补丁中创建新属性。虽然可以通过将它们添加到 SpaceTruckerInputManager.dispose() 函数中手动进行解补丁,但它作为 dispose 函数的一部分自动执行。

如果现在感觉我们比本章早期开始的速度快得多,那是因为这是真的——我们已经通过我们的游乐场演示将驾驶屏幕最复杂的部分处理掉了。游乐场代码被分解成不同的函数,可以拆分出来并制作成它们自己的源文件(进行一些修改),然后由 SpaceTruckerDrivingScreen 消费和编排。我们研究了需要通过在浏览器 URL 中附加查询字符串来加载样本路线数据所需的 SpaceTruckerApplication 状态机更改,然后转向更新控制方案和添加屏幕补丁输入控制图的能力。现在我们已经看到它是如何与应用程序集成的,是时候看看遭遇如何影响驾驶阶段的游戏玩法了。

添加遭遇

要将遭遇从路线规划阶段捕获到驾驶阶段,首先需要将它们捕获到路线中。对 SpaceTruckerEncounterManager.onEncounter 函数进行轻微修改即可完成任务:


const cargoData = this.cargo.lastFlightPoint;
cargoData.encounter = encounter;

代码(突出显示)的添加将遭遇实例添加到路线中的最后一个遥测数据点,以便我们在处理路线时可以使用。在calculateRouteParameters中,我们确保将遭遇数据包含在结果routePath结构中,包括位置、速度和重力加速度。

现在已经找到了并处理了遭遇,我们可以生成遭遇本身了。目前,我们在构造函数中创建一个临时的球形网格,作为生成遭遇时的模板:


// temporary until the encounter spawner is implemented
this.tempObstacleMesh = CreateSphere("tempObstacle",
  this.scene);
this.tempObstacleMesh.visibility = 1;
this.tempObstacleMesh.layerMask = 0;

tempObstacleMesh.visibility设置为1(完全可见)同时将layerMask = 0(完全不渲染),看起来可能有些矛盾,但当我们查看spawnObstacle(seed)函数体以及它是如何将tempObstacle mesh作为模板来创建网格的各个实例时,这就有意义了:


let point = pathPoints[seed];
let {encounter, position, gravity, velocity} = point;
let encounterMesh = tempObstacleMesh.createInstance
  (encounter.id + '-' + seed);

第六章 实现游戏机制中,我们看到了几种高效复制单个网格到场景中的不同方法,数百次甚至数千次。在那个案例中,我们使用了瘦实例来程序化生成和渲染小行星带,因为功能和摩擦的平衡满足了我们的需求。在这个案例中,我们创建更多 CPU 密集型实例网格,因为我们希望启用物理,动画属性如缩放和位置,并对结果网格的特性有更多控制。同时,由于实例都是在 GPU 上的同一绘制调用中绘制的(因此共享渲染特性),改变可见性属性将对所有实例产生相同的效果。然而,layerMask在实例之间并不共享,这就是为什么我们用它来隐藏用于实例化的网格。

尽管从长远来看,这些元素不需要保留在代码库中;tempObstacleMesh就是这样一个例子。虽然它对我们替换为更适合遭遇的网格集非常重要,但它不是立即取得进展所需的功能。我们如何确保我们不会在未来忽视这个区域?由于我们使用 GitHub,我们可以创建一个 Issue 来跟踪它。

注意

请参阅github.com/jelster/space-truckers/issues/92了解之前描述的问题的历史。

与 Issue 中捕获的需求不同,能够在驾驶路线上放置遭遇作为障碍物是一个关键路径功能,因为没有它,我们就无法正确地将这些障碍物绘制到玩家的雷达 UI 显示中。现在我们有了它们,我们有足够的上下文来查看遭遇是如何与 GUI 系统结合来制作迷你地图的。

制作迷你地图

在下一章的大部分内容将专注于 Babylon.js GUI 的同时,我们将涉足 Playground 的initializeGui方法这一主题。

注意

在应用中,这种逻辑包含在/src/driving/目录下的driving-gui.js模块中。除了将加载 Node Material 的代码移动到其中之外,代码与 Playground 中的代码相同。

在本章的开头,我们在初始化驾驶阶段场景部分讨论了视口,并描述了两个主要特征——视口的大小和位置。对于主场景相机,视口拉伸整个屏幕大小,但对我们 GUI 系统来说,视口的定义不同。

GUI 相机

initializeGui函数一开始就立即定义了相机和视口,但它也将相机设置为正交模式。这是一种将 3D 场景渲染到 2D 屏幕上的不同方式,可以基本上总结为是一种渲染对象而不进行距离或透视校正的相机模式:


let guiCamera = new UniversalCamera("guiCam", new
  Vector3(0, 50, 0), scene);
    guiCamera.layerMask = GUI_MASK;
    guiCamera.viewport = new Viewport(0, 0, 1 - 0.6, 
      1 - 0.6);
    guiCamera.mode = UniversalCamera.ORTHOGRAPHIC_CAMERA;
    guiCamera.orthoTop = guiViewportSize / 2;
    guiCamera.orthoRight = guiViewportSize / 2;
    guiCamera.orthoLeft = -guiViewportSize / 2;
    guiCamera.orthoBottom = -guiViewportSize / 2;
    scene.activeCameras.push(guiCamera);

在我们的代码中,guiViewportSize对应于相机在其视场中应该覆盖的单位数。该值被取来用于计算相机相应的顶部、右侧、左侧和底部坐标。最后,guiCamera被推入场景的activeCameras数组以通过相机开始渲染。一旦设置好相机和视口,相机就需要有东西可以渲染,而这正是radarMesh的工作。

作为简单的平面,radarMesh从其StandardMaterial分配的纹理中获得魔力。第一个纹理是我们之前提到过的,那就是从我们加载的NodeMaterial创建的雷达过程纹理(有关NodeMaterial和 NME 的更多信息,请参阅第十一章Shader 的表面摩擦),第二个是我们老朋友的AdvancedDynamicTexture的变体:


let radarMesh = MeshBuilder.CreatePlane("radarMesh", 
  { width: guiViewportSize, height: guiViewportSize },
  scene);
radarMesh.layerMask = GUI_MASK;
radarMesh.rotation.x = Math.PI / 2;
//...
let radarGui =
  AdvancedDynamicTexture.CreateForMeshTexture(radarMesh,
  radarTextureResolution, radarTextureResolution, false);

CreateFullScreenUI是我们过去在定义 GUI 容器时使用过的,而CreateForMeshTexture与之非常相似。CreateForMeshTexture不是创建与屏幕高度和宽度相同的纹理,而是为特定的网格执行相同的操作。然后可以将 GUI 纹理分配给网格的材质,作为其纹理之一:


radarMesh.material = radarMaterial;
radarMaterial.diffuseTexture = radarGui;

在 GUI 系统设置并分配给雷达网格之后,遍历遭遇以创建代表每个遭遇的单独 GUI“亮点”:


encounters.forEach((o, i) => {
    let blip = new Rectangle("radar-obstacle-" + i);
    o.uiBlip = blip;
    blip.width = "3%";
    blip.height = "3%";
    blip.background = "white";
    blip.color = "white";
    blip.cornerRadius = "1000";
    radarGui.addControl(blip);
});
var gl = new GlowLayer("gl", scene, { blurKernelSize: 4,
  camera: guiCamera });

熟悉 CSS 的开发者可能会记得使用在正方形上设置高角落半径的技巧来将其变成圆形,但除此之外,在这段代码中我们之前没有看到过任何东西。在initializeGui函数中发生的最后一件事是创建一个用于照亮雷达并增强其外观的 GUI 专用发光层。定义 GUI 元素意味着将一些新工具放入我们的工具箱中,而将它们用于雷达的运行时行为来验证这些工具不是更好吗?

极坐标下的 Blip 绘图

通常,当我们谈论特定物体的位置,比如遭遇,我们会用它在世界空间中的位置来表示,这是渲染场景的最高级 3D 坐标系。有时,通常在模型及其子网格或骨骼的上下文中,所提到的位置是相对于父网格或变换节点的原点或中心给出的。这被称为本地空间位置,并通过世界矩阵与世界位置相关联。在本章中,当我们加载半挂车模型并移除父根节点时(参见本章前面较早的异步加载资源部分),我们看到了处理这些元素的一个例子。以下图表展示了表示坐标的不同方式:

图 8.7 – 本地空间和世界空间坐标系是笛卡尔坐标系,它们将位置描绘为向量元素的组合

图 8.7 – 本地空间和世界空间坐标系是笛卡尔坐标系,它们将位置描绘为向量元素的组合

有时,用不同的形式表示坐标可能会有优势。极坐标系统就是表示某物相对于另一物的位置的替代方法之一。

在极坐标中,绘图的原点表示单位在空间中的位置,所有其他对象都绘制在该圆的中心。这些对象的坐标可以仅用两个变量来捕捉:角度theta,或θ)和距离r,或半径)。

重要提示

由于雷达在二维空间中,而位置在三维空间中,所以我们使用X轴和Z轴,而Y轴被丢弃。关于该轴上物体位置的信息作为从原点到正在绘制的物体的向量距离的一部分被保留。

一旦我们知道所需的操作,完成这个数学问题就出奇地简单。为了确定向量的距离,我们可以从卡车位置减去遭遇障碍物的位置,并通过Vector3.length()函数获得它,但更直接的方法是使用静态的Vector3.Distance()函数。对于theta的值有多个到达同一目的的路径:


let r = Vector3.Distance(obstacle.absolutePosition,
  absolutePosition);
let theta = Vector3.GetAngleBetweenVectorsOnPlane
  (absolutePosition, up, obstacle.absolutePosition);

Vector3.GetAngleBetweenVectorsOnPlane非常适合我们的使用,因为它会自动考虑卡车和障碍物之间的高度差异,通过将每个对象投影到由卡车的向上向量定义的同一平面上。然而,下一部分有点棘手,因为我们的坐标系将(0, 0)放在中心,而 GUI 系统的放置将原点放在左上角边界:


let posLeft = Math.cos(theta) * r;
let posTop = -1 * Math.sin(theta) * r;
uiBlip.left = posLeft * 4.96 - (r * 0.5);
uiBlip.top = posTop * 4.96 - (r * 0.5);

当设置uiBlip的左和上属性时,在纠正原点位置之前,这些点会根据网格的大小进行缩放。结果,如以下截图所示,是圆形的 Blip,以一种酷炫的方式显示它们相对于玩家的位置:

图 8.8 – 雷达 GUI 元素以玩家(圆圈中心)的相对距离和角度来绘制遭遇的位置

图 8.8 – 雷达 GUI 元素以玩家(圆圈中心)的相对距离和角度来绘制遭遇的位置

虽然这一节可能很短,但它确实充满了甜蜜的知识和成果。关于雷达网格纹理及其构建,还有一些谜团需要揭开,但这些将留待我们旅程的后续章节。通过这一节,我们知道了如何绘制极坐标,以及如何设置带有层掩码和视口的多人相机场景。这是结束我们在这个领域工作的好方法,并为接下来要做的事情做好了准备!

摘要

让我们退一步,看看在这一章中我们走了多远。当我们开始时,我们只有一些路线数据和关于我们想要发生什么的模糊想法。完成它之后,我们现在有一个可以从路线规划到驾驶的从头到尾可以玩的游戏!

在旅途中,我们利用游乐场帮助我们定义了驾驶阶段游戏玩法的原型演示。正是在那个游乐场中,我们学会了如何将原始路线数据转换为可配置的带状网格,其段落数量可以多也可以少,正如我们所希望的那样。当我们学习如何加载和准备此类资产以用于我们的场景时,引入了半挂车 GLB 资产。一旦我们学会了如何设置场景,我们就定义了物理属性,并使用MeshImpostor让我们的卡车能够从路线的墙壁上弹跳,以及一种自动“杀死”卡车的方法,如果它越界了。所有这些工作都为我们与应用程序的顺利集成奠定了基础。

从分而治之的方法开始,我们将游乐场的代码拆分成了不同的功能责任区域。然后,我们将管道连接到从启动屏幕(带有?testDrive URL 查询字符串)或路线规划屏幕的onCargoAccepted事件过渡。使用示例路线数据快速跳入驾驶阶段使得迭代和测试与运行时和输入系统的其余集成变得容易。

对于驾驶阶段,我们的输入处理需求与规划阶段不同,因此为了支持这一点,我们添加了使用更新后的输入到动作映射集来路径化基本输入动作图的功能。为了不让我们的太空卡车在它的路线上感到孤单,我们将注意力转向通过routeData将遭遇与驾驶屏幕连接起来。

在我们向整体routeData添加遭遇数据后,使用(目前)球体网格作为遭遇实例的源是直接的。我们稍后会改变这一点,但在此期间,我们不想阻碍我们为任何侧任务所获得的任何来之不易的动量。同样,我们学习了如何设置我们的备用 GUI 相机以及极坐标——将遭遇绘制到我们的雷达程序纹理/GUI 网格上。综合来看,我们正处于开始我们旅程下一章的绝佳位置,我们将介绍 GUI。

到目前为止,我们一直将 GUI 保持到最小。即便如此,在分配属性值时,相当于基本样板代码的东西可能会相当令人惊讶。没有人想写那么多代码,也没有人想维护它。在下一章中,我们将学习我们如何解决这两个问题,同时介绍一些我们甚至不知道存在的其他问题,当我们深入探讨全新的Babylon.js GUI 编辑器时。

在此之前,如果你想花更多时间探索本章中的想法和概念,请查看下一节的扩展主题部分以获取想法和项目。一如既往,Space-Truckers:github.com/jelster/space-truckers/discussions的讨论板是提问和与同行的 Space-Truckers 交流想法的地方,而 Babylon.js 论坛是参与更大 Babylon.js 社区的地方。如果代码有问题或你有想看到实现的想法?请随意在 Space-Truckers 仓库中创建一个 Issue!

扩展主题

以下是一些你可以尝试的扩展主题:

  • 当卡车在遭遇的设定距离内时,添加“遭遇警告”UI 指示。

  • 当飞船撞到墙壁侧面时,播放适当的声音效果。播放效果的声音大小应与撞击的能量成比例。如果能在碰撞位置定位声音,则加分。

  • 遭遇表暗示着某种静态的东西。通过从 GitHub 上托管的远程索引仓库加载潜在遭遇的列表,使遭遇更加动态。社区成员可以通过提交包含新遭遇定义的 Pull Request 来贡献新的遭遇。一旦被接受并合并,遭遇就可以在游戏会话中使用。

  • 作为前一条清单的前提,为每次遭遇添加使用不同网格/材料组合的能力是必要的。从遭遇数据中读取网格 URL,但请注意,你并不是为每次遭遇的实例创建新的网格/材料!

  • 另一个遭遇功能可能是让每种遭遇类型能够定义和控制其行为。在下一章的高级协程使用部分中概述了一种简单且酷的方法来实现这一点。

第九章:计算和显示评分结果

无论游戏是以软件的形式实现,还是以剪裁的纸板的形式实现,几乎所有的游戏,无论其来源或格式如何,都有内在的方式在游戏过程中向玩家提供关于其表现的反馈。无论是反映进球数还是玩家保持钢球在挡块之间不落下的能力,评分过程是游戏与玩家在最直观层面上连接的地方。

在本章中,我们将介绍两个新的强大工具,这两个工具都是 Babylon.js 版本 5 的新特性:src/route-planning/route-plan-gui.js。回想一下,为了仅显示一个基本的 GUI,就需要编写大量的样板代码、易出错的代码,以及最终令人厌烦的代码。涉及的代码大多属于“使这个对象变成这种颜色,并放置在这里”的类型,这使得在运行时难以可视化组件和元素的外观。GUIE 允许开发人员或设计师将应用程序的表示与行为逻辑分开——这是一个大多数开发者都非常熟悉的概念!除了 GUIE 之外,我们还将介绍另一个极其强大的工具——协程。

协程的行为和构建方式对于那些阅读过第四章,“空间卡车手 - 状态机”部分的人来说非常熟悉,但与专门用于管理我们的应用程序状态的具体目的不同,协程是由一个任意定义的函数生成器构建的(有关 JavaScript 中function生成器的复习,请参阅第四章,“创建应用程序”),并将其附加到BabylonJS.Observable上。大多数时候,这个 Observable 将是场景的onBeforeRenderObservable,这意味着协程在每一帧之前执行,但任何 Observable 都可以运行协程。yield关键字与我们将很快看到的某些其他元素结合使用的行为使得协程成为在游戏逻辑需要跨越多个渲染帧时使用的完美工具,我们将利用这一特性来显示评分结果。

作为对 GUIE 和协程的考察的一部分,我们将在将评分跟踪逻辑放入游戏的其他部分之前,构建一个可重用的对话框系统,该系统将作为我们的评分对话框和结果屏幕的基础。尽管这可能看起来像是一种倒退的方法,但首先能够显示分数将帮助我们了解应用程序的其他部分需要跟踪和计算什么。还有多少事情可以做,而且应该做?当然!总会有更多的事情要做,但在软件开发中,知道哪些事情必须做,以及哪些事情只需要,是一项重要的技能。

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

  • 介绍 Babylon.js GUI 编辑器

  • 构建可重用对话框组件

  • 计算分数

  • 使用协程创建评分对话框

技术要求

对于本章,软件或硬件方面没有新的或额外的要求,但在 Babylon.js 文档或其他地方有一些主题,在我们探索这些领域时可能会很有用:

介绍 Babylon.js GUI 编辑器

模板代码是指具有简单、标准化和经常重复特性的代码。作为软件开发人员,出于一些非常好的原因,通常最好自己不编写这种类型的代码。首先,模板代码的本质是重复的,这使得它容易受到语法或其他表面逻辑缺陷(即,拼写错误、手指粗大等)的影响。其次,维护起来很困难,因为当需要引入更改时,这些更改通常需要在模板代码的整个范围内进行。最后(至少对我们来说),阅读和编写这种类型的代码真的很无聊。

为了解决这些问题(以及其他相关问题),Babylon.js 团队创建了 GUIE。作为 Babylon.js v5.0 版本中引入的众多新工具和功能之一,GUIE 在 Babylon.js 生态系统中填补了一个重要的空白。就像它的兄弟一样,动画曲线编辑器、节点材质编辑器和 Playground,GUIE 及其关联的片段服务器托管在gui.babylonjs.com,并且具有类似的双重能力,可以与唯一 ID 和修订版一起工作以实现持久性,或者直接与 JSON 文件一起工作。

重要提示

Babylon.js 支持两种基本的 GUI 类型:2D 和 3D。2D GUI 使用高级动态纹理(见高级动态纹理部分)渲染到实用层,而 3D GUI 系统在实用层上渲染网格。本章以及本书的大部分内容主要关注 2D GUI。然而,2D 和 3D 系统具有非常相似的 API。有关 3D GUI 系统的更多信息,请参阅doc.babylonjs.com/divingDeeper/gui/gui3D

在我们开始查看 GUIE 的界面和功能之前,如果我们先从复习或入门开始,了解 Babylon.js GUI 组件在高级动态纹理ADT)层面的工作方式,这将是有用的。

高级动态纹理

在这本书的整个过程中,我们一直在使用 ADT 和 2D GUI 系统,但到目前为止,我们还没有尝试窥视 ADT 的内部,看看它做了什么。要做到这一点,让我们先去掉“高级”这个词的部分,首先关注更基础的动态纹理DT)。

DT 是一个设计时集成组件,在一端暴露了 HTML5 Canvas 绘图 API;在 Babylon.js 一端,它暴露了一个 BABYLON.DynamicTexture。因为它派生自常规的 BABYLON.Texture,它通常通过将 DT 分配到材料中适当的纹理槽来渲染。在 playground.babylonjs.com/#5ZCGRM#2 的游乐场中演示了如何使用 DT 绘制简单文本的基本原理,但任何 Canvas API 都可以通过 DT 的 getContext 函数访问。

注意

查看 developer.mozilla.org/en-US/docs/Web/API/Canvas_API 了解更多关于画布 API 及其不同功能和能力的信息,以及 doc.babylonjs.com/divingDeeper/materials/using/dynamicTexture 了解更多关于 BabylonJS DT 的信息。

以这种方式访问画布 API 为希望渲染字符串或其他 UI 元素的开发者提供了巨大的灵活性,但代价是要求这些开发者必须管理大量本质上属于模板代码的内容。听起来熟悉吗?Babylon.GUI 系统是集成一端在画布 API 之上的更高层抽象,就像其前身动态纹理一样,高级动态纹理构成了另一端。

简而言之,ADT 是由 Babylon.GUI API 生成和管理的过程纹理。就是这样。如果觉得这个定义有些令人失望,考虑到之前的铺垫,那么你很幸运,因为细节要复杂得多,远不止一个简单的过程纹理。我们可以从想象 ADT 如何融入整体场景和渲染过程开始:

图 9.1 – 画布 API 和 Babylon.js 渲染层,它们承载着众多不同的功能,如检查器、发光层、Gizmos 等

图 9.1 – 画布 API 和 Babylon.js 渲染层,它们承载着众多不同的功能,如检查器、发光层、Gizmos 等

如果我们将 HTML 画布类比为用于绘画的布画布,那么一个层就像画布上的一层独立的油漆;多个层重叠和混合以创建整个作品。正如现实世界的画布一样,像素(或油漆块)放置的顺序对于最终外观很重要——通常情况下,最后放置在画布上的颜色将是该像素的占主导地位的颜色。

当使用AdvancedDynamicTexture.CreateFullScreenUI创建时,ADT 被渲染为那些层之一,ADT 的isForeground属性决定了其层是否在所有其他层之前渲染。关键的是,这也意味着 ADT 会受到与其他层相同类型因素的影响(例如,层遮罩和后处理;参见第八章构建驾驶游戏异步加载资源部分了解更多)。当全屏 UI 不是合适的工具时,可以通过使用AdvancedDynamicTexture.CreateMeshTexture函数创建它,就像使用任何纹理一样使用AdvancedDynamicTexture。这就是我们在第八章构建驾驶游戏制作迷你地图部分所做的那样,所以这是一个好兆头,表明我们准备好向更高层次迈进,开始使用更好的工具来处理AdvancedDynamicTexture,无论其类型如何。同样,GUIE 将为我们节省大量的时间和精力,所以让我们简要地浏览一下,并开始工作吧!

使用 GUIE 进行 UI 设计

如往常一样,Babylon.js GUIE 的最新文档可以在doc.babylonjs.com/toolsAndResources/tools/guiEditor找到,但一些基本原理仍然值得回顾。最顶部的水平菜单,带有汉堡图标,包含用于管理缩放级别、复制和粘贴控件等功能的控制项。

在导航面板的空白区域单击会显示 ADT 的属性。这些属性除了用于渲染布局画布外,还包括用于以各种格式加载和保存 GUI 的按钮。以下截图从右到左显示了 GUI 编辑器的不同区域 – 导航树、布局画布和属性面板,分别:

图 9.2 – GUIE 的三个主要工作区域,从左到右:导航面板、布局画布和属性面板。布局显示了当前选中的 layoutGrid 容器元素。来源:https://gui.babylonjs.com/#923BBT#37

图 9.2 – GUIE 的三个主要工作区域,从左到右:导航面板、布局画布和属性面板。布局显示了当前选中的 layoutGrid 容器元素。来源:gui.babylonjs.com/#923BBT#37

控制树可以在左侧的导航面板中看到,与布局画布由可插入的控件图标的垂直列表隔开。这些控件中可能更重要的一组是各种类型的容器。

StackPanelsGrids,再加上一些ScrollViewersRectangles来使布局更加完整,容器元素的行为正如你所期望的那样,如果你习惯了这些概念。图 9.2 所示的 GUI 是一个简单的对话框设计,内容被拆分到布局 Grid 的三个单独的行中。这个控件反过来又包含在 Rectangle 对话框边框中,而对话框边框又包含在整个 UI 的 dialogRoot 容器中。

如果你不太熟悉容器及其行为,快速阅读 BJS 文档中关于容器的部分可能值得(并且信息量丰富!)在doc.babylonjs.com/divingDeeper/gui/gui#containers。可以通过在视觉布局面板中拖动处理柄或直接设置特定值来调整和布局视觉元素 – 使用前者来获得近似值,使用后者来达到像素级的精确度!

注意

当前支持的控件列表及其相关文档部分的链接可以在doc.babylonjs.com/toolsAndResources/tools/guiEditor#supported-controls找到。

行索引从零开始,因此中间行是第一行,包含对话框的主要内容。以下图表说明了 layoutGrid 的三个行如何分别分配 25%、50%和 25%的可用高度:

图 9.3 – layoutGrid 及其子控件的简化视图。顶部和底部行各占可用高度的 25%,而中间行在渲染时分配剩余的 50%可用高度

]

图 9.3 – layoutGrid 及其子控件的简化视图。顶部和底部行各占可用高度的 25%,而中间行在渲染时分配剩余的 50%可用高度

让我们依次查看每一行。第一行包含titleText控件;正如其名称所暗示的,它正是你所期望的那样 – 一个用于显示对话框标题的容器。第二行,中间行包含主要显示内容,因此需要为它的滚动查看器(允许任意长或宽的子内容 – 这是一个值得注意的有用特性,稍后将会用到)以及它自己的userCanceluserAccept按钮留出最多的空间。这些按钮将在下一节中与点击逻辑连接,并且将使用相对(百分比)定位来确保按钮保持在它们各自的侧面。

注意

HTML/CSS 开发者可能愤怒地想知道为什么我们不使用列和跨单元格,或者使用水平 StackPanel 的对齐方式。这些方法确实很棒——如果可用跨单元格或全对齐对齐,但它们目前不可用(在撰写本文时),因此必须寻求替代方法!

我们 GUIE 之旅的最后一站与其说是编辑器的功能,不如说是一个强烈推荐的命名工作流程实践,即在树中命名控件:

![图 9.4 – 命名控件的控件树结构。拥有清晰、指示性的名称对于有效地将 GUIE 与应用程序集成至关重要。布局网格容器中的子元素在树中显示它们各自的[row:column]索引

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/go-dis-babylon/img/Figure_9.4_B17866.jpg)

图 9.4 – 命名控件的控件树结构。拥有清晰、指示性的名称对于有效地将 GUIE 与应用程序集成至关重要。布局网格容器中的子元素在树中显示它们各自的[row:column]索引

如果你阅读过某种类型的奇幻体裁,那么你就会知道,拥有某物的名称赋予了对该物的控制力,我们的 GUI 控件树也不例外!我们将 GUI 定义与 JavaScript 逻辑结合的集成模式将依赖于使用控件名称的权力在需要时召唤它,但我们将看到如何使用Control.findByName作为 Babylon.js GUI 及其核心框架中功能提供的整洁集成选项的一部分!

与 GUIE 的集成

是时候超越 GUIE,看看我们如何在简单的 Playground 设置中利用其输出了。位于 https://playground.babylonjs.com/#WIVN8Z#6 的 Playground 将是我们本节的开端;我们将在下一节中构建并完成它;也就是说,构建可重用对话框组件。现在,让我们运行 Playground,并在显示区域中的任何地方点击或轻触以召唤一个对话框。点击其中一个按钮将关闭或生成一个新的对话框,具体取决于点击的是哪个。

现在,让我们专注于createScene函数。它非常简短——少于 40 行代码,其中大部分代码用于订阅DialogBox组件和场景的onPointerObservable的各种 Observables。定义初始对话框opts对象并创建DialogBox实例,完成了我们的 Playground 的场景创建逻辑,使我们能够专注于如何将const DIALOG_GUI_SNIPPET = "923BBT#32"这一行转换为交互式组件,使我们更接近了解DialogBox的工作原理。

DialogBox类定义的正上方——第 60 行——定义了CONTROL_NAMES常量:


const CONTROL_NAMES = Object.freeze({
    cancel: 'userCancel',
    accept: 'userAccept',
    titleText: 'titleText',
    bodyText: 'dialogText',
    acceptText: 'userAcceptText',
    cancelText: 'userCancelText',
    dialog: 'dialogBorder',
    bodyScrollViewer: 'bodyContainer',
    bodyStackPanel: 'bodyStackPanel',
});

回想一下我们提到名称的重要性。这就是那个讨论变得重要的地方!它也成为了我们代码中唯一需要了解我们 GUI 结构具体信息的部分,这使得我们可以在一定程度上修改我们的 GUI,而无需对应用程序代码进行相应的更改。

好的——现在,我们有一个可以用来通过代码访问控件的控件名称映射,但我们还没有加载任何内容供我们的代码访问。我们需要创建一个 AdvancedDynamicTexture 的实例——全屏模式是可以的——并且我们还想确保文本和线条在最终渲染的任何大小上都是清晰和锐利的:


this.advancedTexture = AdvancedDynamicTexture
     .CreateFullscreenUI("dialog", false, scene,
          Texture.NEAREST_NEAREST, true);

在缩放纹理时使用 NEAREST_NEAREST 作为采样方法可以获得最佳文本效果,而最后一个参数的 true 标志则启用自适应缩放,以获得无论缩放如何都看起来很好的效果。现在我们已经有了可以托管 GUI 的东西,是时候将此 GUI 加载到 ADT 中了。

因为我们想将 DIALOG_GUI_SNIPPET 作为源来加载我们的 GUI,所以我们需要使用 AdvancedDynamicTexture 实例中的 parseFromSnippetAsync 方法。由于该函数是异步的,这意味着我们可以从适当标记的 async 函数中等待其完成:


this.scene.executeWhenReady(async () => {
     await this.advancedTexture
           .parseFromSnippetAsync(DIALOG_GUI_SNIPPET,
             false);
     this.dialogContainer.isVisible = false;

一旦 advancedTexture 从片段服务器完成加载 GUI 定义(并且在前面的情况下,选择不重新缩放纹理),它就可以通过 advancedTexture.getControlByName() 访问。为了避免代码中的重复,我们可以在类或对象中定义属性访问器来封装获取或设置这些控件值的逻辑:


get dialogContainer() {
    return this.advancedTexture
           .getControlByName(CONTROL_NAMES.dialog);
}
get titleText() {
    let ctrl = this.advancedTexture
            .getControlByName(CONTROL_NAMES.titleText);
    return ctrl.text;
}
set titleText(value) {
    let ctrl = this.advancedTexture
            .getControlByName(CONTROL_NAMES.titleText);
    ctrl.text = value;
}

在前面的代码中,有两个获取控件的示例,以及一个控件文本值的示例。此外,最后一个属性显示了设置 titleText 控件的文本值的类似过程。这些属性访问器和类似的其他组件构成了 DialogBox 类的核心部分,这是下一节的主题。

AdvancedDynamicTexture.getControlByName

现在,使用这个工具、数据和代码的组合,是时候将理论付诸实践了。我们需要一种更简单的方法来实现对话框的概念,在我们的应用程序中至少有两个地方需要对话框功能——路线确认和评分。这个问题说明了需要构建一次可以使用在多种情况下的东西。

构建可重用对话框组件

一个可重用组件能够在特定代码库的多个位置和上下文中使用。设计一个可重用组件与设计一个单一用途组件在几个方面有所不同。其中最相关的一点是,可重用组件的功能必须设计成用户可定制的,而无需为使用它而重新编写基本代码。

当我们查看如何将 advancedTexture.getControlByName 包装在获取或设置访问器中时,我们已经检查了 DialogBox 的某些部分,因此让我们在此基础上制作一个重要的提醒/笔记。

重要提示

CONTROL_NAMES 枚举列出了 DialogBox 类实现的全部属性,但属性的数量多于控件的数量。获取或设置如 titleTextbodyText 这样的属性是直接操作文本控件的 text 属性。

我们组件的关键部分将是初始化(构造)逻辑,这很重要,因为它需要解析 GUI 数据、入口和退出管理,以及处理按钮点击等事件。在检查了这些功能的运作方式之后,我们将把这些单独的部分组合起来,以构建路线确认对话框提示。

构建 DialogBox 类

DialogBox 类的构造函数接受一个选项对象和一个场景实例作为其两个参数。这些参数主要用于预先填充对话框的内容,但 displayOnLoad 参数是一个行为标志,它控制 DialogBox 在加载和初始化完成后是否应该可见。当值为 false 时,必须显式调用 show() 方法来显示对话框:


const {
  bodyText, titleText,
  displayOnLoad, acceptText,
  cancelText
} = options; //...later...
if (bodyText) { this.bodyText = bodyText; }
this.titleText = titleText ?? "Space-Truckers: The Dialog
  Box";
this.acceptText = acceptText ?? "OK";
this.cancelText = cancelText ?? "Cancel";

构造函数逻辑确保对话框将包含任何必需的内容,即使它们没有被调用者指定。之前,我们查看 AdvancedDynamicTexture 的创建,以及如何使用 parseFromSnippetAsync 填充 GUI 元素。这是我们用于 Playground 从片段服务器加载的模式。对于应用程序,我们将使用 advancedTexture.parseContent() 加载定义 UI 的 JSON 文件——这是一个非异步方法,它还消除了在 scene.executeWhenReady 的回调中运行初始化逻辑的需要,我们在 Playground 中使用了它。这是我们 Playground 的 DialogBox 类和最终将出现在 Space-Truckers 应用程序中的唯一有意义的区别。这突出了使用 PG 进行迭代代码设计的强大功能!

构造函数的其余部分致力于订阅和连接 DialogBox 类的子组件。我们的两个按钮的点击事件处理程序被类包装,并且分别由 onAcceptedObservableonCancelledObservable 处理:


this.#acceptPointerObserver =
  this.acceptButton.onPointerClickObservable
    .add(async (evt) => {
        await this.onAccepted();
        this.onAcceptedObservable.notifyObservers();
    });
this.#cancelPointerObserver =
  this.cancelButton.onPointerClickObservable
    .add((evt) => {
        await this.onCancelled();
        this.onCancelledObservable.notifyObservers();
    });
this.scene.onDisposeObservable.add(() => {
    this.dispose();
});

为了避免资源泄露,我们在非公共类成员(用 # 前缀表示)中捕获了从订阅方法返回的观察者,并在 dispose 方法中进行清理:


dispose() {
    if (this.#showTimer) {
        this.#showTimer = null;
    }
    this.onAcceptedObservable?.clear();
    this.onAcceptedObservable?.cancelAllCoroutines();
    this.onCancelledObservable?.clear();
    this.onCancelledObservable?.cancelAllCoroutines();
    this.advancedTexture?.clear();
} 

任何正在进行的异步操作都必须取消,包括任何协程(有关协程的定义,请参见 使用协程创建评分对话框 部分)。我们的游乐场中的 createScene 函数演示了当初始确认 DialogBoxonAccept 处理程序中被销毁时,如何实现这一点,其位置被新的 DialogBox 替换。

我们的基本 DialogBox 定义了与用户交互的两个显式交互点:接受和取消按钮。它还定义了两种行为:显示和隐藏。接下来,我们将学习这两个是如何相互关联的,以及如何确保显示和隐藏方法仅在 DialogBox 类完成转换后完成。

处理按钮点击和更改可见性

除了处理 acceptButtoncancelButton 的点击事件外,onAcceptedonCancelled 函数还为 DialogBox 类提供了自定义器,以便在通知外部观察者事件之前运行自定义逻辑——默认行为通过在触发 Observable 之前隐藏对话框来显示这一点。


    onAccepted() {
        return this.hide();
    }
    onCancelled() {
        return this.hide();
    }

onAcceptedOnCancelled 都返回一个 Promise,当对话框完成隐藏时解决。如果调用者关心等待对话框完全显示或隐藏,可以使用标准的异步或 Promise 解决模式——即 await myDialog.show()myDialog.hide().then(…)。至于显示或隐藏 DialogBox 的逻辑,它使用 BABYLON.setAndStartTimer 工具函数与 Scalar.SmoothStep 函数一起触发 DialogBox 类的淡入或淡出(注意,由于空间原因,以下列表中省略了一些代码):


return new Promise((resolve, reject) => {
   this.dialogContainer.alpha = 1;
   this.#showTimer = setAndStartTimer(
   {
       timeout: this.#fadeInTransitionDurationMs,
       onTick: (d) => this.dialogContainer.alpha = Scalar
                      .SmoothStep(0.998, 0, d.completeRate),
       onEnded: () => {
           this.advancedTexture.isForeground = false;
           this.dialogContainer.isVisible = false;  
           resolve();
        },
        breakCondition: this.dialogContainer == null
     });
   }
});

在前面的代码中,大部分动作发生在 setAndStartTimer 选项的 onTickonEnded 回调中。对话框以不透明度 1 开始,并在经过一段时间的 #fadeInTransitionDurationMs(大约 800 毫秒)后以不透明度 0 结束。在此期间,使用 onTick 参数的 completeRate 进行插值,给出一个从 01 的值,表示计时器完成进度的距离。

onEnded 回调将 DialogBox 类从前景渲染中移除(参见本章前面的 高级动态纹理 部分),并在解决原始 Promise 之前将 GUI 的 isVisible 设置为 false。另一方面,breakCondition 确保如果 DialogBox 实例在完成隐藏或显示动画之前被销毁,计时器不会尝试调用已销毁的对象。

注意

show() 函数几乎与 hide() 函数相同,但它更像是一个镜像反转图像,而不是一个克隆。这是因为它从完全透明开始,最终完全隐藏。

让我们回顾一下如何使用 DialogBox 类的五个简单步骤:

  1. 创建一个包含至少一个 guiData 字段的 opts 对象,其中包含一个片段 ID:

    let opts = {
        bodyText: "Your flight plan appears to be viable!"
            + '\n'
            + "Would you like to file it with Space-
              Truckers Traffic Control (STC)?",
        titleText: 'Route Planning Success',
        displayOnLoad: false,
        acceptText: 'Launch!',
        cancelText: 'Retry',
        guiData: DIALOG_GUI_SNIPPET // e.g., "923BBT#32"
    };
    
  2. 实例化一个新的 DialogBox 实例,传入之前创建的 opts 对象和场景的引用:

    let dialog = new DialogBox(opts, scene);
    
  3. 将观察者附加到对话框的 onAcceptedObservableonCancelledObservable 以响应用户输入(在这种情况下,调用 createScoringDialog):

    dialog.onAcceptedObservable.add(async () => {
            dialog.dispose();
            dialog = createScoringDialog(null, scene);
    });
    dialog.onCancelledObservable.add(()=>console.log
      ('cancelled'))
    
  4. 如果(可选的)displayOnLoad 标志未设置为 true,则调用 show 方法以显示 DialogBox 类:

    dialog.show();
    
  5. 要取消或隐藏对话框,请点击 cancelButton 或调用 hide() 函数。要延迟操作直到 DialogBox 类完全淡出,可以等待从 hide 返回的 Promise:

    await dialog.hide();
    

在我们的可重用 DialogBox 完成概念验证形式后,让我们快速看一下如何通过查看路线规划屏幕如何使用它来提示玩家进入游戏玩法下一阶段,来了解与 Space-Truckers 应用程序集成的实践。

在成功规划路线后提示用户

从游乐场对 DialogBox 类的更改不需要太多。然而,如 构建 DialogBox 类 部分所述,我们将从使用从远程服务器加载的片段切换到从游戏资源文件夹加载的 JSON 文件。

从 GUIE 保存 GUI JSON 定义后,定义被添加到 /src/guis 文件夹中的 gui-dialog-buttons-scroll.json。不过,需要做一个重要的更改,所以打开文件并找到任何外部资源(*.png),将它们的 URL 从绝对路径更改为指向 assets 文件夹中适当文件的相对路径。例如,用作 DialogBox 背景的图像在修改后看起来会是这样:


"source":"/assets/menuBackground.png"

DialogBox 类本身位于 GUI JSON 旁边,在 guiDialog.js 文件中,并且根据我们从片段服务器切换到 JSON 的变更,我们必须在将 guiData 属性值传递给 DialogBox 构造函数之前,将此导入添加到文件顶部:


import stackedDialog from "./gui-dialog-buttons-
  scroll.json";
// later…
this.advancedTexture.parseContent(stackedDialog, false);

转到 SpaceTruckerPlanningScreen,我们需要在文件中添加对 DialogBoximport


import DialogBox from "../guis/guiDialog";

SpaceTruckerPlanningScreen 已添加一个新的 routeConfirmationDialog 属性,在构造函数的末尾初始化,其中包含的逻辑如果你已经阅读了本章的全部内容,应该非常熟悉:


this.routeConfirmationDialog = new DialogBox({
    bodyText: 'Successful route planning! Use route and
      launch?',
    titleText: 'Confirm Flight Plan',
    acceptText: 'Launch!',
    cancelText: 'Reset',
    displayOnLoad: false
}, this.scene);
this.routeConfirmationDialog.onAcceptedObservable.add(() =>
  {
    this.routeAcceptedObservable.notifyObservers();
    this.gameState = PLANNING_STATE.RouteAccepted;
    this.routeConfirmationDialog.hide();
});
this.routeConfirmationDialog.onCancelledObservable.add(()
  => {
    this.routeConfirmationDialog.hide();
    this.setReadyToLaunchState();
});

现在,毫无疑问,实际用于确认对话框中的复制品可能需要一些改进,但至少目前它能完成任务——也许你会是那个提交拉取请求将其改为更有趣内容的人?

在有趣的话题上,对话框的onAcceptedObservable处理程序做了几件有趣的事情。首先,它通知任何感兴趣的各方玩家已经接受了路线。然后,它更新gameState以反映新的现实,在隐藏routeConfirmationDialog并允许订阅routeAcceptedObservable的任何逻辑从该点开始处理之前。这与 Playground 示例没有太大不同,而且也不需要太多时间就能启动!但我们现在想保留这种感觉,因为接下来,我们将对应用程序进行一系列有针对性的更改,以收集、处理和计算游戏得分数据。

计算得分

从玩游戏中获得的大部分乐趣来自于游戏可以以不同方式向玩家提供反馈——积极的或消极的。这是游戏设计师与玩家在情感层面上建立联系的大好机会。将积极的事件和结果与玩家的行为联系起来,在游戏和玩家之间创建了一个反馈循环,而在游戏中最古老、最真实的关系之一就是积分累积的概念。

DialogBox中使用的评分系统最初是空的,但在给出最终得分之前,它会逐行显示每个类别的得分。

在构建捕获和计算得分的逻辑之前,定义一个示例得分中的所需得分数据模型是有用的。这是评分过程逻辑的期望输出,无论该逻辑如何生成数据。这将让我们了解在应用程序的其他地方需要做出哪些更改以支持评分系统。

得分系统设计

Space-Truckers生成并使用的得分数据可以分为三大类:得分因素乘数最终得分。得分因素是反映路线规划和驾驶性能基本属性的类别。遭遇次数、路线长度和初始发射力都是在路线规划阶段固定并设置的,但在驾驶阶段(直到玩家到达目的地)货物的状况是动态的(为了提供背景,已提供了一些示例值):


scoreFactors: {
    routeLength: 12450.25,
    cargoCondition: 0.768,
    encounters: 125,
    launch: 100.00
},
multipliers: {
    transitTime: { expected: 180, actual: 150, factor: 1.2  },
    delivery: 1.0,
    condition: 0.768,
    encounterTypes: 1.05
},
finalScores: {
    'Base Delivery': 1000,
    'Route Score': 14940,
    'Cargo Score': 11474,
    'Delivery Bonus': 10000,
    'Encounters': 1312,
    'Final Total': 38726
}

以下scoreFactors是乘数。这些值被用于评分计算,以各种方式修改一个或多个scoreFactors,我们将在下一节“累加和统计分数”中详细介绍。然而,在我们继续之前,还有一件事要做。最后一个——你甚至可以说是一个最终——部分要涵盖。finalScores是从scoreFactor和乘数组合中得出的分类和汇总的值。这就是最终将以“这里是底线……”这样的方式显示给玩家的内容。

忽略任何关于如何捕获评分数据的细节仍然是一种有用的策略,因为尽管我们可能知道评分数据的一般形状,但直到我们知道如何计算这些分数,我们不会确切知道需要捕获哪些数据和位置。

累加和统计分数

评分逻辑包含在 src/scoring/spaceTruckerScoreManager.js 文件中。类似于我们之前使用示例评分进行隔间化的方式,使用此组件的消费者只需要调用默认导出 computeScores 并传入一个路由数据结构,就可以返回一个 score 对象。computeScores 函数是一个简单的协调函数——它的唯一目的是协调调用其他各种计算单个评分区域的函数:


let computeScores = function (route) {
    let score = createDefaultScoring();
    calculateEncounterScoreToRef(route, score);
    calculateRouteScoreToRef(route, score);
    calculateCargoScoreToRef(route, score);
    calculateBonusScoreToRef(route, score);
    calculateFinalScoreToRef(score);
    console.log(score);
    return score;
}

在前一个列表的第二行中的 createDefaultScoring 函数包含 0 或空白值。随着 score 对象在各个 calculateXXXScoreToRef 方法之间传递,其值通过连续的函数调用逐步构建和使用。

这些函数名称上的 ToRef 后缀表示它们将修改一个参数(通常是按照惯例提供的最后一个参数),而不是创建一个新实例。这在 VectorMatrix 对象中最为常见,但命名的一致性对于代码库的长期健康至关重要!接下来是各个子节,它们将详细说明评分计算的各个方面。

由于我们仍在开发过程中,我们不会过于担心使这些计算平衡和调整到我们可能想要的程度。我们需要做的是建立一个基本的方法来提供一种动态评分体验,这样我们稍后准备好平衡和调整时可以轻松返回。

遭遇分数

我们首先计算遭遇分数。一开始,我们就知道我们想要得到一个遭遇列表,并且我们将想要使用这个列表来累加每个遭遇的单独修正值,以得到最终的遭遇修正值。如果我们假设路由参数包含一个 pathPoints 对象集合(有关详细信息,请参阅 /src/driving/spaceTruckerDrivingScreen.calculateRouteParameters 函数),并且 pathPoints 集合中的任何给定条目可能或可能不包含一个关联的遭遇,该遭遇包含一个十进制的 scoreModifier 值,那么我们可以使用简单的 mapreduce 操作:


const { pathPoints } = route;
const encounters = pathPoints
    .map(p => p.encounter)
    .filter(e => e);
scoreFactors.encounters = encounters.length;
let encounterModifier = 
    1 + encounters.map(e => e.scoreModifier)
        .reduce((prev, curr, cidx, arr) => {
            return prev + curr;
        });
multipliers.encounterTypes = encounterModifier;
let encounterScore = 100 * encounters.length * 
  multipliers.encounterTypes;
finalScores['Encounters'] = encounterScore;

之前的代码使用了一个简单的提取函数调用map,该函数检索scoreModifier值——一个数字。接下来,它将scoreModifier数字数组传递给reduce函数。Array.reduce(如果你还不熟悉)是一个有用的聚合工具,它将一个函数作为其主要参数。在遍历数组arr时,函数会依次对每个curr元素调用,将prev操作的结果与cidx位置的curr元素值一起传递。这仅仅是一种花哨的说法,即reduce操作会计算一个数字数组的所有元素的总和!这个聚合值成为encounterModifier,它与总的遭遇次数一起用来确定总的遭遇得分值。

路线得分

路线得分计算与遭遇得分计算略有不同。路线得分的主要因素是整体路线的长度(货物在到达之前需要行驶多远),但还有几个同样重要的修正因子。当涉及到路线的transitTime时,有两个相关的值:计划中的运输时间和实际的(驾驶阶段)运输时间。这两个值的比率加上一个常数,给出了transit.factor,这是一个重要的乘数,它以两种方式使用。首先,它应用于distanceTraveled;然后应用于launchForce值,这在路线规划阶段使用。第一个值从第二个值中减去,以产生最终的路线得分值:


transit.factor = 0.5 + route.transitTime /
  route.actualTransitTime;
finalScores['Route Score'] = 
(route.distanceTraveled * transit.factor) – 
(route.launchForce * transit.factor);

货物得分

货物得分主要基于货物到达时的状态,这意味着它反映了玩家在驾驶阶段的表现。货物开始时的状态值为 100。当发生遭遇或足够快的碰撞时,路线路径可以降低该值(有关更多信息,请参阅捕获得分数据部分),该值在经过状态乘数缩放后用作货物得分的基础:


const { cargoCondition } = route;
scoreFactors.cargoCondition = cargoCondition;
let cargoScore = 10 * cargoCondition *
  multipliers.condition;
finalScores['Cargo Score'] = cargoScore;

奖励得分

如果玩家以完美的状态交付他们的货物,那么将获得额外的奖励。在这种情况下,交付奖金应用于finalScores


if (route.cargoCondition >= 100) {
   s.finalScores['Delivery Bonus'] = DELIVERY_BONUS;
} else { s.finalScores['Delivery Bonus'] = 0;}

最终得分

一旦所有各种子得分都被计算并相乘,就是时候将它们全部加起来以得到我们的总得分。在填充了BASE_DELIVERY_SCORE之后,我们使用Object.values生成一个数字数组,我们(听起来熟悉?)将其传递给另一个reduce操作以得到最终的“总分”得分值:


let { finalScores } = score;
finalScores['Base Delivery'] = BASE_DELIVERY_SCORE;
let finalScore = Object.values(finalScores)
  .reduce((prev, curr) => prev + Number(curr));
score.finalScores['Final Total'] = finalScore;

将这些计算组合起来有助于我们了解路线中已经可用的数据以及需要收集的数据。毕竟,关于游戏会话的信息远不止路线路径!

捕获得分数据

以样本评分数据作为指导,我们可以逆向工作,以确定在捕获之前生成评分数据的应用程序中的位置。这可能会导致需要更新或更改现有的数据结构和代码,但没关系,因为我们也将对所需的更改进行修改,以便玩家能够完成驾驶阶段,并看到他们最终得分以全貌显示!

丰富路线数据

第一个,也可能是最大的变化是,我们为SpaceTruckerPlanningScreen添加了一个新的routeData属性,它包装了游戏后期评分计算所需的所有数据(有关更多信息,请参阅累计和总计分数部分):


    get routeData() {
        return {
            route: this.cargo.routePath,
            launchForce: this.launchForce,
            transitTime: this.cargo.timeInTransit,
            distanceTraveled: this.cargo.distanceTraveled
        }
    }

Cargo对象的routePath跟踪遭遇和其他路径特定数据,而其他值提供基线旅行时间和路线长度。遭遇已经被作为与遭遇相关的cargoData的一部分捕获,但需要为在route-planning/gameData.js文件中列出的每个遭遇添加额外的scoreModifier字段:


{
   name: 'Rock Hazard',
   id: 'rock_hazard',
   image: hazard_icon,
   probability: 0.89,
   scoreModifier: 0.019
}

还有更多的工作要做,但这已经完成了评分方面的数据收集部分。接下来,我们需要添加一个触发器来启动评分过程(前提是玩家已经完成了路线…)并显示评分对话框。

完成驾驶阶段

到目前为止,SpaceTruckerDrivingScreen.killTruck函数一直在无差别地执行其名称所暗示的严酷职责。然而,今天却不同。今天,卡车的死神有了良心:


let closestPathPosition =
  path3d.getClosestPositionTo(mesh.absolutePosition);
// not close enough!
if (closestPathPosition < 0.976) {
    this.reset();
    return;
}
this.completeRound();

当方法被onMeshIntersectExit动作触发器调用时,它会将网格的绝对(世界参考)位置与路线最近的 Path3D 段进行比较。参见第八章构建驾驶游戏生成驾驶路径部分,了解更多关于 Path3D 及其与路线路径的关系。

注意

Path3D 以介于 0(开始)和 1(结束)之间的标准化路线形式公开位置。

如果卡车意外地离目的地太远而退出其路线路径(从而触发此方法),那么收割的严酷任务将继续像过去一样进行。让我们不要沉溺于过去,而是展望涉及调用SpaceTruckerDrivingScreen类的completeRound方法的美好未来。需要发生的头两件事是我们想要隐藏驾驶阶段的 GUI,我们通过将适当的layerMask设置为0来实现这一点。接下来,我们将屏幕过渡到DRIVING_STATE.RouteComplete状态,以防止对可能影响评分的模拟进行进一步更新,说到评分,这紧接着就是:


completeRound() {
    this.gui.guiCamera.layerMask = 0x0;
    this.currentState = DRIVING_STATE.RouteComplete;
    this.route.actualTransitTime = this.currentTransitTime;
    // gather data for score computation
    let scoring = computeScores(this.route);
    let scoreDialog = createScoringDialog(scoring, this);
    scoreDialog.onAcceptedObservable
     .addOnce(() =>
       this.onExitObservable.notifyObservers());
    scoreDialog.onCancelledObservable
     .addOnce(() => this.reset());
    this.scoreDialog = scoreDialog;
}

一旦收集并计算了得分数据,就会调用createScoringDialog(来自/src/scoring/scoringDialog.js)来完成必要的DialogBox创建和管理;completeRound需要做的只是将onAcceptedObservableonCancelledObservable属性连接到适当的逻辑。然后,从驾驶屏幕的角度来看,我们就准备就绪了!

createScoringDialog函数与这本书非常相似;它从熟悉的部分开始,然后在进展过程中加入一些完全出乎意料或不熟悉的内容,直到最后,似乎一切都像魔法一样运作。让我们通过查看函数的熟悉部分来结束这一节:


    let opts = {
        bodyText: 'Time to earn payday!',
        titleText: 'The Drayage Report',
        displayOnLoad: true,
        acceptText: 'Main Menu',
        cancelText: 'Retry'
    };
    const { scene, soundManager } = drivingScreen;
    const sound = soundManager.sound('scoring');

    let scoreDialog = new DialogBox(opts, scene);
    let dialog = { scoreDialog };
    dialog.height = "98%";
    let scoringCo = scoringAnimationCo();

这与playground.babylonjs.com/#SQG1LV#28中的 Playground 略有不同,但这仅仅是因为 PG 没有SpaceTruckerSoundManager来检索和管理下一节使用的声音。在这段代码的最后几行之前,并没有什么不寻常的地方。这也是介绍 Babylon.js v5 中更令人兴奋的功能之一的绝佳机会——协程!

计算得分本身的逻辑尽可能简单,不再复杂——它只需要直接传递给它的数据来操作,但那些数据需要从某个地方来。不同的得分类别来自游戏的不同组件;遭遇增加它们的乘数,行驶和路线规划中的通行时间被计算,而卡车在行驶过程中跟踪货物的健康状况。这些因素和乘数都贡献于最终得分,这些得分会在得分对话框中显示。

使用协程创建得分对话框

如果你来自 Unity、Unreal 或其他游戏引擎的工作背景,你可能熟悉协程的概念。在这些环境中,协程的定义与在 Babylon.js 中定义的方式非常相似:一个跨多个渲染帧运行的状态方法。

虽然它可能暗示了多个线程的存在,但在大多数框架中(例如 Unity 和当然还有 JavaScript!),通常并非如此。C#编程语言使用迭代器以及yield关键字来实现协程,但在 JavaScript 中,我们使用一个(剧透警告!)函数*生成器。没有人能预料到第四章,“创建应用程序”中的回调吧!我们不会将它们作为应用程序状态机的一部分来使用,而是要定义使得分对话框的得分条目从零开始计数的逻辑,同时播放收银机类型的声响。最后,我们将通过查看一个独立的 Playground 示例来将事情推向高潮,展示如何设计由多个独立可重用行为组成的控制器系统。

检查函数生成器

有关 JavaScript 函数生成器的更详细概述,请参阅 第四章创建应用程序太空卡车手——状态机 部分。这里有一个快速示例,帮助提醒我们它们是如何工作的以及如何使用它们。假设我们的设计师为打印报告的行设计了一套调色板。我们可以定义一个 nextColor() 星函数,它将在每次迭代时产生一个新的十六进制颜色字符串:


function* nextColor() {
    while (true) {
        yield "#0d5088";
        yield "#94342c";
        yield "#e2ba77";
        yield "#787b6d";
    }
}
let colorPicker = nextColor();

当通过调用 nextColor() 生成函数时,它将始终按顺序从列表中产生一个颜色,当请求时。这在哪里发挥作用?createScoringBlock(label) 函数负责创建和样式化实际显示在得分 DialogBox 中的 GUI 元素,每次调用时都调用 colorPicker.next() 来产生一个新的值:


// ...inside the createScoringDialog function scope
// ...inside the function* scoringAnimationCo scope
function createScoringBlock(label) {
    let scoreBlock = new TextBlock("scoreLine",
      `${label}`);
    scoreBlock.width = "100%";
    scoreBlock.color = colorPicker.next().value;
    scoreBlock.textHorizontalAlignment =
      Control.HORIZONTAL_ALIGNMENT_LEFT;
    // …snip…
    return scoreBlock;
}

这就是我们对 function* 概念的简要回顾,所有内容都整理得很好。现在,让我们深入到 scoringDialog.js 中,看看这些如何在协程和 Babylon.js 中发挥作用,我们将解开 scoringAnimationCo 并将其用于我们的 DialogBox 中!

使用协程计算玩家得分

协程很棒,因为它们允许开发者通过相对简单的逻辑(当正确实现时)表达复杂的行为。每当协程想要将控制权返回给调用者时,它都会调用 yield ——带有或没有参数(见 高级协程使用 部分)。协程的 BABYLON.Observable 的返回时间和方式。

重要提示

Babylon.js v5 可观察 API 中的新功能是 Observable.runCoroutineAsyncObservable.cancelAllCoroutines 函数。有关更多信息,请参阅 doc.babylonjs.com/divingDeeper/events/coroutines

如果将协程附加到场景的渲染事件可观察对象之一,每当宿主可观察对象被触发时,协程将在每一帧运行。如果附加到 scene.onPointerObservable,则每当指针移动或与场景交互时,协程都会触发。当与 JavaScript 闭包的工作方式结合使用时,这非常强大——由于迭代函数是一个有状态的构造,它可以记住并跟踪在多个模拟/渲染帧中演变的事件和条件。

这使得协程非常适合实现一种类似“收银台”风格的累计玩家得分,并配合之前作为 createScoringDialog 函数一部分创建的 DialogBox 类来展示最终总计。协程逻辑可能看起来很简单:给定由得分管理器生成的得分对象(见 累计和总计得分 部分)和场景,遍历最终得分属性中的每个属性,并通过从零开始计数来在 DialogBox 类中显示其值:

![图 9.5 – scoringAnimationCo 行为的逻辑流程图。圆圈代表带有可选 Tools.DelayAsync 使用的 yield 语句。矩形列出采取的行动img/Figure_9.05_B17866.jpg

图 9.5 – scoringAnimationCo 行为的逻辑流程图。圆圈代表带有可选 Tools.DelayAsync 使用的 yield 语句。矩形列出采取的行动

前面的图示显示还需要处理几个其他部分:bodyStack StackPanel 的高度需要调整以适应添加到其中的新行,包含 bodyStack 控件的滚动条需要设置为新的最大值,以确保当前文本行完全可见,等等。

尽管这个逻辑看起来很复杂,但它包含的代码行数远远不到 100 行!如果我们只看协程的实际逻辑,而不包括状态管理代码,我们甚至需要更少的代码来编写:


for (let i in finalScores) {
    yield Tools.DelayAsync(500);
    // ...snip... compute and adjust height    
    yield Tools.DelayAsync(1800);
    if (skipCountUp) {
        // display score right away
    }
    else {
        const MAX_COUNT = 50;
        while (frameCounter <= MAX_COUNT) {
            let currProgress = frameCounter / MAX_COUNT;
            sound.play();
            let speed = Scalar
                    .SmoothStep(0, score, currProgress);
            scoreBlock.text =
                     `${label}.........${speed.toFixed()
                      .toLocaleString()}`;
            frameCounter++;
            yield Tools.DelayAsync(50);
          }
        }
        yield;
      }
      return;

MAX_COUNT 的值是通过实验任意确定的;它控制计数动画的长度。进度由 SmoothStep 函数控制,该函数开始时速度较慢,然后在接近结束时加速,然后缓慢停止。每次将 Tools.DelayAsync 作为 yield 的参数传递时,协程将暂停自身一段时间——尽可能接近指定的时间——然后继续执行。

注意

由于帧时间增量并不总是等于指定的确切时间,协程可以暂停的时间可能会略微长于指定的时间。

一切都说完了,最终返回的是 returns 而不是 yields,这表示完成并通知宿主的 onBeforeRenderObservable 可以清理并销毁该协程函数实例。从启动代码的角度来看,我们只有两行简单的代码——一行用于创建迭代函数,另一行用于启动它运行:


let scoringCo = scoringAnimationCo();    
scene.onBeforeRenderObservable.runCoroutineAsync(scoringCo);

在这个场景中,我们不想阻塞执行并等待协程完成,然后继续执行 createScoringDialog,但如果我们在做不同的事情,比如作为协程的一部分进行异步 HTTP 调用,那么等待或捕获 runCoRoutineAsync 返回的 Promise 将是明智的。因此,它可以像任何其他异步操作一样使用和传递,允许更高级的场景和复杂的行为。

高级协程使用

除非你是泰坦尼克号的乘客,否则有好消息:这只是冰山一角!因为协程利用了函数迭代器的底层机制,可以使用 yield* 操作符将多个 function* 迭代链接成一个单一的协程,如 playground.babylonjs.com/#5Z2QLW#1 中的游乐场所示。

注意

查看 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield* 获取更多关于使用 yield* 操作符的详细信息及示例。

yield* 操作符在 function* 体的上下文中使用,并提供了一种“传递”另一个迭代函数结果的方法——或者,这是定义中容易忽略的部分(强调添加):

yield* 表达式用于委托给另一个生成器或可迭代对象。”

虽然在我们的例子中没有使用,但这将允许开发者编写一个协程,例如,从由设备传感器填充的数组中生成值流,以及其他许多应用。在我们的例子中,我们类似于通过调用另一个 function* 将可重用代码提取到函数中,使用 yield* 操作符。

看起来是一个小细节,但这种执行其他迭代函数的能力使我们能够使用软件设计的强大组合模式,将简单的构建块组合起来以表达复杂的行为。我们从 function* think() 协程开始。它被适当地命名,因为它的任务是决定球体网格接下来要做什么:


function* think() {
    while (true) {
        yield Tools.DelayAsync(1500);
        yield* moveToTarget(new Vector3(PERIMETER / 2, 1,
          0));
        yield Tools.DelayAsync(1500);
        yield* patrolCo();
        yield* moveToTarget(new Vector3(0, 1, 0));
    }
}

将前面的代码视为主要控制器,或者更通俗地说,是游戏对象的 AI。它可以读取环境并做出决定。在我们的情况下,它等待一秒半后,使用以下代码片段中列出的 moveToTarget 函数和期望的目标位置调用该函数。这使球体从其位于右侧边界的中间位置移动。在短暂的延迟之后,调用 patrolCo 函数。

function* patrolCo 是另一个组合元素,它结合了多个 moveToTarget 迭代以及逻辑,以在每次移动方向改变时改变球体的颜色:


function* patrolCo() {
    let targetVector = new BABYLON.Vector3(0, 1, 0)
    yield;
    sphereMat.diffuseColor = BABYLON.Color3.Random();
    targetVector.set(PERIMETER / 2, 1, 0);
    yield* moveToTarget(targetVector);
    sphereMat.diffuseColor = BABYLON.Color3.Random();
    targetVector.addInPlaceFromFloats(0, 0, PERIMETER / 2);
    // ...snip... 
    yield* moveToTarget(targetVector);
    return;
}

每组 yield* 语句将执行委托给 moveToTarget 函数,这是本例中的真正的工作马。这种行为正如其名——它将行为的主语(在我们的例子中是球体网格)尽可能移动到给定的目标世界位置。maxDelta 值限制了球体在任何给定帧中可以覆盖的地面量(由于协程是由 onBeforeRenderObservable 承载和执行的):


const maxDelta = 0.0075;
function* moveToTarget(targetPosition) {
    let hasArrived = false;
    while (!hasArrived) {
        let dir = targetPosition.subtract(sphere.position);
        if (dir.length() <= 0.75) {
            hasArrived = true;
        }
        dir.scaleInPlace(maxDelta);
        sphere.position.addInPlace(dir);
        yield;
    }
    return;
}

移动方向是通过减去两个相关位置向量来计算的,其结果用于确定球体是否到达目的地,以及通过添加指向 targetPosition 的缩放向量来移动球体。到达后,迭代函数将控制权返回给调用迭代函数——无论是 patrolCo() 还是 think(),然后继续其迭代链中的下一步。

这个简单的示例可以很容易地通过添加额外的 function* 定义来扩展额外的行为和逻辑。就像一个行为库或工具箱,简单的行为如 moveToTarget 被缝合成更复杂的行为,如 patrolCo,而 patrolCo 又由不断沉思游戏世界的整体 think 函数迭代器编排。可以快速以这种方式构建一个完整的非玩家演员/控制器!希望通过在隔离的游乐场中展示这些概念,更容易看到组合如何使整体大于部分之和。

摘要

在本章中,我们取得了许多成果。从设计并保存 DialogBox 到片段服务器和 JSON 开始。学习如何与 DialogBox 组件一起使用它,并通过添加路线规划确认对话框进行测试。

带着这些初步结果,我们转向 Space-Truckers 中使用的评分系统以及计算游戏每个评分区域的逻辑。完成这项任务所需的数据在过程中变得明显,因此我们对 Space-Trucker 应用程序 进行了必要的修改以捕获评分数据。由于我们已经建立了基础对话框结构,因此很容易从捕获和样本评分数据中创建 评分对话框

然而,仅仅在我们的评分对话框中显示分数是不够的,所以我们还在 Babylon.js v5 中引入了另一个新功能:BablyonJS.Observable(但主要在 onBeforeRenderObservable 中使用),协程允许简单地编写和执行复杂的跨帧逻辑。通过 scoringAnimationCo,最终得分对象的每一行都会从零开始显示并计算到最终值。

离开评分对话框,我们通过学习如何使用多个 moveToTargetpatrolCo 的协程来结束本章。

在下一章中,我们将通过深入研究环境、基于图像的照明IBL)以及如何使用 Babylon.js 的基于物理的渲染PBR)来探讨 Space-Truckers 的空间。从为 IBL 转换图像的工作流程到添加后处理效果,我们将看到如何通过几行代码轻松地制作出既吸引人又高效的内容!

扩展主题

重要的是不要过多关注 DialogBox 设计 UI 的细节——这本书不是一本关于图形设计的书,这让大家如释重负——所以这里有一些想法和资源,可以帮助你将 UI 冒险提升到下一个层次:

这两个样本分别是一个主菜单系统和游戏内菜单及库存系统——当你以本章所学为基础,将它们结合起来时,你可以构建出哪些类型的东西呢?

  • 空间卡车主菜单在很大程度上是命令式编写的,而不是像 GUIE JSON 文件那样的声明式。正如我们所见,声明式数据驱动的 UI 构建和维护要容易得多,所以尝试将这种知识应用到主菜单上,通过用DialogBox替换一些在代码中创建的 GUI 组件来实现。

  • 可组合的协程可以提供一种简单易行的方法来为游戏或应用程序添加有趣的行为。为驾驶阶段中的任意遭遇实例添加一种运行协程“行为”的方式。遭遇本身应该提供协程,但它需要提供当前游戏状态信息:

    • 三个组件协同工作可以帮助干净地分离和定义此功能:

      • 执行“思考”的行为组件

      • 一个提供状态信息的“思考”上下文

      • 行为可以执行的一系列动作(例如,“移动”、“进食”、“散开”、“获取目标”等等)

    • 遭遇协程可能加载网格和材质,设置一些值,并在开始其行为“思考”循环之前执行其他初始化任务。

    • 动作可以是其他协程行为,例如巡逻行为。

第十章:通过光照和材质改善环境

欢迎来到第十章!这一章由数字 5 带来了两次。Babylon.js v5 不仅带来了强大且快速的特性,还带来了一套新工具,以帮助处理游戏引擎可能从框架中请求的几乎所有领域。我们已经与其中的一些工具一起工作过,包括刚刚过去的这一章关于GUI 编辑器GUIE)。之前,我们除了与GUIEPlayground一起工作外,还与Inspector中的粒子编辑器一起工作。但这还不是全部,远非如此。

在本章中,我们将介绍IBL 工具包沙盒,一个巨人的阴影笼罩着我们。强大的节点材质编辑器NME)将是下一章的主题,我们将利用这一章来为它做准备,通过提升我们对 3D 图形编程中一些重要主题的知识水平。

当谈到游戏或 3D 应用程序的图形体验时,光照可能是对场景整体外观和感觉贡献最大的单一因素。像我们之前或将要讨论的许多主题一样,关于这些主题有大量的更深入、更精心编写、更全面的文本库。因此,我们的目标将是提供一个基于实际使用场景的坚实基础,以阐述这些原则。无论希望实现什么样的视觉效果,当前实时 3D 渲染的最佳和最高效的光照技术是基于图像的光照IBL),其中场景的主要光源,正如其名所示,是一种特别准备好的图像纹理。

注意

1993 年的电影《侏罗纪公园》开创了这项技术,作为捕捉现场光照以用于场景计算机生成元素的方法。

然而,这一章不仅仅关于光照。在场景中有意义地讨论光照而不涉及材质的概念是非常困难的。简单来说,材质是我们用来描述光线如何与表面交互的数学术语。这个定义中包含了很多内容,但像往常一样,Babylon.js 提供了一个捷径,即PBRMaterial。这有助于将数学中最复杂的部分——无论是这本书中还是你的代码中——隐藏在光鲜的抽象背后,而我们将负责知道需要设置哪些参数以及设置什么值。

在我们尝试从照明主题的火流中汲取知识之前,还有一些其他的事情需要我们处理。请确保查看技术要求部分,其中包含一些与本章主题相关的帖子、书籍和文章的链接。还有一个你可以用于 Scrabble 游戏或用来给你的朋友(他们不幸没有像你一样阅读这本书)留下深刻印象的时髦词汇列表,以及一个用于处理和准备图像以供项目使用的免费和付费软件列表。不要因为你的知识快速增长而感到内疚——当你阅读第一部分了解自上次访问代码库以来我们所做和改变的所有不同事情时,你会感到很高兴。

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

  • 材料和照明以及 BRDF

  • 使用 PBR 材料和 IBL 场景

  • 色度映射和基本后期处理

技术要求

本章的源代码位于github.com/jelster/space-truckers/tree/ch10的 ch10 目录中。虽然我们仍将查看一些 Playground 代码,但本章的大部分工作都在其他地方。对于处理图像和纹理,以下非详尽的工具列表将有助于准备和转换图像资产以供 Babylon.js 使用。

工具

以下工具将帮助你在本章中:

图片

术语表(简略版)

以下是一些在本章或阅读其他关于 3D 照明和材料主题的资源时可能会遇到的常见缩写和术语列表。这远非完整,但可以作为扩展词汇的起点:

  • 直接绘制表面DDS):一种用于存储高分辨率图像的文件格式。这包括 MIP 贴图。Babylon.js 支持所谓的“遗留”DX1 DDS 格式。

  • MIP 贴图:在某些 3D 图形领域被称为金字塔,MIP 贴图是一系列逐渐缩小、分辨率较低的原始图像的再现。这用于许多应用,如细节级别LOD)和存储预计算的照明值。

  • 基于物理的渲染PBR):这是一种模拟光线与某些表面材料相互作用后行为的技术。镜面/光泽度模型和粗糙度/金属度模型是两种方法。对于两个主要参数,分别有镜面/光泽和粗糙/金属的配对,其值在[0,1]范围内。

  • 基于图像的照明IBL):一种场景照明的技术,它通过将图像的球形投影结合进来提供照明。

  • 天空盒:一个内部纹理化的网格立方体,相机位于其中。这是通过使用一个特别布局的单个图像或六个单独的图像来实现的。位于立方体内部的相机将纹理视为非常遥远的地方。

  • 环境纹理:这是一种特殊的纹理类型;它是“IBL”中的“I”。

  • BRDF:代表双向反射分布函数(发音类似于“Bird”),这是一个数学函数,它向整体渲染函数贡献项,该函数将反射角度与入射和出射光量相关联。

  • 渲染函数:当实现时,这被称为渲染管线。这是一个用于计算 2D 像素最终屏幕颜色的数学函数,该像素描绘了 3D 场景的一部分。该像素的最终颜色值受许多不同因素的影响,例如光照或摄像机的位置。

  • 材质:这是一个资产或代码组件,当应用于网格几何体时,定义了光线对网格的影响行为。

  • 亮度:这是对给定单位面积内光量的测量。

  • 动态范围:场景中最亮和最暗部分之间的比率。

  • 色调映射:用于将 HDR 图像适配用于非 HDR 显示或媒介。

  • 色彩空间:特定文件或图像格式可以表示的颜色范围。这通常以每通道字节数表示;例如,R8G8B8A8。

推荐阅读

在阅读本章之前、期间或之后,这里有一些资源链接,可以帮助你快速浏览。其中一些更侧重于概念方面,而文档链接则非常实用:

材料和 BRDF

将 3D 场景比作现实世界的电影工作室场景是一个显而易见但很有用的类比。有些部分是显而易见的,比如场景和舞台、摄像机和灯光,而有些则不是。网格是演员和场景道具,而材料则是他们的服装。本节内容全部关于服装和灯光,但如果不深入探讨场景中光线建模的理论基础,讨论这两者都很难。

这一部分内容有点复杂,所以这里先快速浏览一下我们将要讨论的内容。首先,我们将涉猎一点符号数学和一些非常基础的微积分。接下来,我们将探讨光如何以不同的方式反射和与表面相互作用,以及它在 3D 中的建模或近似方法。这将为我们学习材料及其在高级数学中的关系提供一个坚实的基础。之后,我们将介绍 PBR 和环境,以结束我们的探索之旅。现在是时候深入研究了!

介绍 BRDF

光线以入射或反射的形式进行建模——用下标 i 和 r 表示——代表的是测量到的光线量,这些光线要么是照射到物体上(正在被反射),要么是从物体上发出的(从它反射出来)。这两个场景的术语是辐射,表示从物体反射出的光线,以及辐照度,表示入射光线的量。入射辐射和反射辐照度之间的比率是通过 BRDF 的一些推导来计算的:

如果你不是 3 级微积分或更高水平的专家,这个数学表达式看起来可能会让人害怕,但当我们用它的工作原理重新表述方程时,它并不像看起来那么糟糕。反射辐射的变化(dLr)取决于入射(Li)光线与表面法线(n)之间的角度——用于计算θ,输入值由(ωi, ωr)的组合表示。任何实现此功能的代码都必须满足三个重要的约束条件,以真实地模拟物理系统:

  • fr(ωi, ωr)的结果必须大于或等于零。

  • 交换(ωi, ωr)的术语会产生相同的结果。这被称为互易性。

  • 能量必须守恒。换句话说,进入特定区域的总辐射通量和从该区域发出的总辐射通量必须小于或等于一。

幸运的是,我们不必直接处理这个方程的实现,但了解驱动 Babylon.js 中高级抽象的底层力量是好的。在本节稍后,我们将探讨PBRMaterial参数如何影响底层 BRDF,但首先,我们将继续探索光照背后的理论和概念。

在 3D 应用程序中如何模拟光线

为了减少所需的光照计算的数量和复杂性,我们需要简化我们对光的处理方式。就我们的目的而言,光表现为从其源头发出的射线,然后以确定的方式从表面反射。在这些计算中,可能会有大量单个参数对结果有贡献,但核心中,只有少数参数包含对光照计算至关重要的术语:光方向(I)、辐射(L)、表面法线(n)和观察位置(V)。由于需要计算物体表面法线和入射点之间的角度,BRDF 对每个光源进行一次评估,对场景中的每个光样本进行一次评估:

![图 10.1 – 参与光照计算的基参数。归一化向量 I 和 V 分别代表光的方向和观察者的方向,而归一化向量 n 指向表面法线的方向图片

图 10.1 – 参与光照计算的基参数。归一化向量 I 和 V 分别代表光的方向和观察者的方向,而归一化向量 n 指向表面法线的方向

归一化向量 I 指向光源,而向量 L(有时在方程中用ω表示)提供强度。当你将这些结合起来,你就得到了光在物体上每个颜色上的亮度(亮度)。不同的光源使用不同的方程来计算 I 和 L 的值。两个例子是点光源,它在所有方向上均匀辐射光,和聚光灯,它在单一方向上辐射光。这两种类型的光都能照亮物体,但它们的属性导致了表面交互中的不同行为。

注意

确保 I、N 和 V 向量被归一化,以保留变换后值之间的关系。最终值通过从 L 计算出的颜色或标量值进行缩放。

辐射度的质量是衡量每平方米面积上入射光量的度量,如果想要技术上精确的话。更通俗地说,辐射度是特定光源的亮度。与辐射度相辅相成的是颜色。从物理学的角度来看,颜色是由特定光包的波长定义的,或者是由光子包含的能量量。在计算上,颜色通常表示为 Vector3 或 Vector4 量,这取决于是否使用了 alpha 透明度通道。能够将颜色作为向量处理是一种非常有用的技术,因为整个向量计算工具箱就可以应用于混合和混合颜色。然而,在我们知道要执行哪些类型的计算之前,我们需要更多地了解反射光的类型。

散射

当光线达到观察位置时,它可以沿着几乎无限多种不同的路径组合到达。散射照明项指的是从物体表面均匀散射的光。另一种说法是,一束光线击中物体表面会向所有可能的方向散射散射光。散射的光会受到物体漫反射材料设置指定的颜色或从纹理查找的影响:

![图 10.2 – 散射光向所有方向散射图片 10.02_B17866.jpg

图 10.2 – 散射光向所有方向散射

镜面

照明模型中的镜面项表示从物体直接反射到观察者的光。根据该术语的值,这可以使物体具有“闪亮”的外观,近似于光滑或粗糙的表面。当入射光束、物体和观察者之间的角度接近 90 度时,镜面项会受到强烈影响:

![图 10.3 – 镜面光直接被观察者反射图片 10.03_B17866.jpg

图 10.3 – 镜面光直接被观察者反射

发射

与其他照明术语不同,发射项与外部光源无关,而是由物体本身产生的光。对于照明设计来说,重要的是它不会照亮场景中的其他物体。因此,发射照明有时被称为自发光:

![图 10.4 – 发射照明或自发光只照亮物体本身图片 10.04_B17866.jpg

图 10.4 – 发射照明或自发光只照亮物体本身

到目前为止,我们已经讨论了 Uem、Uspec 和 Udiffuse 术语的定义,但我们没有说明如何首先计算这些值,也没有说明如何组合这些值。如果你对此好奇,可以参考本章的“推荐阅读”部分,了解更多关于这些方程式细节的信息。我们将要讨论的最后一种照明项是环境光,这是最简单的照明形式之一。

环境光

当在 3D 应用程序的上下文中讨论环境光时,它指的是一类对表面有影响但路径并不直接来自光源的光照贡献者。一个直观的例子是多云无日照的日子。在这样的日子里,来自环境的光似乎来自每个方向;它是全方向的。阴影(即环境遮挡AO)可以预先烘焙,制作简单且渲染速度快:

![Figure 10.5 – 环境光不依赖于方向,来自光源环境的间接照射。它通过场景中具有恒定值的单一颜色进行近似]

![Figure 10.05_B17866.jpg]

![Figure 10.5 – 环境光不依赖于方向,来自光源环境的间接照射。它通过场景中具有恒定值的单一颜色进行近似]

由于光线从光源到接收器经过物体表面的间接路径,我们通过为每个场景设置单一颜色来近似环境贡献。环境光没有方向,因此其亮度在整个场景中保持恒定。

定义光的基本特性和行为只是光照问题的一部分。问题的另一部分是表面材料特性,它决定了来自特定方向且具有给定视位置的入射光会发生什么。现代 3D 引擎和资产创建工具已经接受材料的概念作为定义物体表面在任何给定角度和点对光反应的手段。

材料和纹理

在概念层面上,一种材料是一种BRDF实现;材料包含一组数据与逻辑的组合,这些数据与逻辑被插入到整体图形管道的相关术语中(参见第十一章着色器的表面处理,了解更多关于图形管道和着色器的内容),以可编程着色器、纹理和其他属性的形式存在。结合前几节的概念,我们将看到为什么使用术语材料来涵盖着色器和纹理的特定配置是有意义的,并介绍允许以PBR形式实现实时逼真光照场景的近似。

材料概述

在 Babylon.js 中,有两个基本的通用材质组件,以及一系列专门的组件库,允许您以非常少的努力添加酷炫和有趣的效果。例如,熔岩材质通过程序化模拟熔岩效果应用于网格,而毛皮材质则使网格看起来像毛茸茸的。您甚至可以使用 VideoTexture 将外部源的视频渲染到场景中!浏览 Babylon.js 材质库 并了解如何使用它们,请参阅doc.babylonjs.com/toolsAndResources/assetLibraries/materialsLibrary

标准材质是 Babylon.js 中的工作马材料。一般来说,材质(例如,标准材质)将颜色、纹理属性和着色器分组,这有一个重要的性能影响:每个不同的材质都会在单独的调用中绘制到屏幕上。通常,通过减少绘制调用可以获得更好的性能,因此应避免创建特定材质的新实例,而应尽可能将现有实例分配给网格。PBR 材质是 Babylon.js 对 PBR(物理基础渲染)的实现,我们将在稍后详细讨论这一技术。

无论是使用标准材质还是 PBR,都取决于场景的需求。大多数情况下,正确设置的 PBR 材质 将比使用 标准材质 的材质具有更高的照片级真实感。这种真实感是以更高的计算成本为代价的。考虑到涉及到的更复杂的 BRDF,这并不总是最合理的选择。例如,在路线规划屏幕上对恒星(如太阳)的描绘应使用 标准材质,因为它通过发射光照自发光。发射光照并不一定与 PBR 过程相矛盾,但在太阳的例子中,任何 PBR 的视觉优势都丢失在发射的眩光中。

如前所述,材质是一个容器和包装器,用于资产和可执行逻辑。回顾更早的讨论,它负责在其 BRDF 中计算各种光照项。对于专用材质,环境光镜面光发射光漫反射选项可能因材质类型而异,但对于 标准材质PBR 材质,每个光照项都可以通过颜色或一组一个或多个不同的纹理图像来指定。

纹理和材质

为特定项设置颜色,例如漫反射项,会在该材质覆盖的每个网格上均匀引入该颜色。这可能适用于某些场景,但它会使场景看起来非常单调乏味。将纹理分配给不同的项是这里的方法,而且这也是复杂性开始显著增加的地方(好像它还不够复杂一样!)另一个使事情复杂化的因素是,你想要使用的纹理的选择和类型可能在 StandardMaterialPBRMaterial 之间有所不同。

重要提示

你可能在 Babylon.js 的文档和 API 中注意到对 PBRMetallicRoughnessMaterialPBRSpecularGlossinessMaterial 的提及。这些材质提供了快速从使用 StandardMaterial 转换到 PBRMaterial 的途径,几乎不需要费力,或者可以快速将 PBR 添加到场景中,但代价是对参数的精细控制。有关简化 PBRXXXMaterials 和通用 PBRMaterial 之间差异的更多信息,请参阅 doc.babylonjs.com/divingDeeper/materials/using/masterPBR#from-metallicroughness-to-pbrmaterial

对纹理资产进行着色是 3D 图形设计的一个子技能,需要练习、耐心和以略微扭曲的方式观察世界的能力。如果一个网格的材质是它的服装,那么材质的纹理就是网格的上衣。网格为每个顶点定义了一个二维坐标集,通常称为 (u,v) 而不是 (x,y)。它是纹理上的一个 [0,1] 点,当采样时,定义了网格上该点的颜色。这种查找被称为使用 纹理贴图

关于贴图的话题,想想如何将球形地球投影到一张平面上。尽管澳大利亚的面积大约是三倍,但格陵兰岛看起来大小相同。这是将球面表面映射到平面表面固有的扭曲,其明显程度在很大程度上取决于纹理创建时覆盖的几何形状。当我们介绍球形环境贴图时,我们将回到这个话题,但回到 纹理贴图 的话题,它在我们当前讨论中最相关的是查找方面。

提示

当查看 Babylon.js 的 PBRMaterial 时,AlbedoDiffuse 的作用就像 ReflectivitySpecular 的作用。可以为每个光照项设置颜色,作为纹理提供相同效果的替代或补充。

正如我们在之前关于 如何模拟光线 的讨论中看到的,照明不仅仅是根据纹理查找特定颜色并根据光源距离调整其强度。前面的 提示 给出了在材质之间转换基础纹理术语的类比,但通常在材质中会涉及不止一个纹理。

当额外的图像纹理与 diffuseTexture 中的 albedoTexturebumpTexture 混合时。一个 环境(有时也称为 遮挡)纹理和其他光照因素,如 表面法线 (N),不是常规纹理图像的一部分,而是作为包含在一个或多个单独纹理图像中的数据提供。大多数 3D 内容创建软件具有生成和创建这些替代类型纹理的不同能力,因此,大多数可以通过资产市场等途径获得的 3D 模型已经包含这些纹理。充分利用这些纹理的关键在于知道将哪些东西连接到哪些值,因此,在我们探讨如何提供这些参数之前,让我们先了解我们可以提供哪些参数!

PBR 作为一种不同的 BRDF 类型

通过 BRDF 的“透镜”观察 PBRMaterial,尽管输出与任何其他 BRDF 的形状(即格式)相同,但到达这些值的方法却相当不同。这体现在一系列不同的参数上,这些参数控制着材料对光照行为的非常具体方面。以下是一个常用属性列表及其简要描述,按照它们在 Babylon.js 文档中的顺序排列,文档地址为 doc.babylonjs.com/divingDeeper/materials/using/masterPBR。此页面包含许多 Playground 示例,展示了设置各种 PBR 属性的不同效果,这些效果在理解可用的选项时可能很有用:

  • 金属度: 这影响镜面项并决定材料有多像导电或金属物质。

  • 粗糙度: 这指定了表面的平滑程度。更平滑的表面将会有更尖锐的镜面高光(即,光亮的斑点)。

  • #sub-surface): 这是一类用于从肤色到半透明反射材料等物品中的属性。这特别适用于 Babylon.js v5.0 中的折射和半透明度。它还控制散射效果。

  • #clear-coat): 这用于描述材料最顶层表面与光照的相互作用。一辆光亮干净的抛光汽车在其实际漆色之上仅可见一层清漆层。

  • #anisotropy): 这用于塑造非对称反射(镜面高光)并且高度依赖于视角和入射角度。

可以在 PBRMaterial 上配置许多不同的参数和设置,因此值得退后一步看看 PBR 中包含的内容。

PBR 和 IBL

从正式的角度来说,useRoughnessFromMetallicTextureAlphauseMetallnessFromMetallicTextureBlue可以允许资产设计师以及开发者,在运行时提供材质数据时具有广泛的灵活性,从而在创意上更加高效。当内存和计算资源有限时,这一点至关重要——处理单个纹理远比处理三个单独的纹理要好。PBR 可以在场景中产生非常好的效果,但与 IBL 结合使用时效果更佳。

重要提示

尽管与肠易激综合症IBS)的缩写有表面上的相似之处,但 IBL 与你的或任何人的肠道都没有关系。PBR 也是如此,以防需要澄清。

IBL是一种照明场景的技术,它从图像源中获取场景的主要光照信息。尽管可能仍然存在其他光源,例如点光源,但它们的存在是为了提供次要和/或补充照明。IBL 是 PBR 技术的一个明显不同的类别,但如果不使用可以利用它的 PBR 材质来设置 IBL 场景,那就没有太多意义!IBL 的工作方式是在渲染过程中,采样一个特别捕获和准备的高动态范围HDR)图像——这是一个作为立方体贴图准备的图像——来提供 Li 值,而不是特定的光源。

环境纹理和反射纹理

使用 IBL 和 PBR 的一个好处是,在正确的设置下,那些原本复杂难以通过程序建模的事物,可以简单地从物理光照模拟中产生。以城市场景为例。

夜幕降临,黑暗的餐厅门口上方闪烁着霓虹灯。场景的中心是一个四岔路口,一辆汽车在那里发生了事故。挡风玻璃上的反射显示出周围的建筑,而破碎的玻璃窗片在霓虹灯招牌的廉价啤酒广告的闪烁中闪耀着。从破裂的消防栓中涌出的水流到街道上,在逐渐扩大的水坑的波纹面上,可以看到驾驶员卷曲的头发从安全气囊的两侧爆炸开来,她可见的眼睛似乎随着波纹的水坑而颤抖。多么丰富的描述啊!

在传统的或更准确地说,如今更传统的渲染方法中,前面段落中描述的几乎所有细节都需要为单一用途和目的定制制作和编码。使用 IBL 和 PBR 以及适当的纹理资产,可以让设计师创建和使用只有在你能在廉价黑色侦探小说中读到的那种细节的场景!IBL 设置的组成部分自然是图像部分。这个图像被称为环境纹理,正如之前提到的,它是为PBRMaterial实例提供光照信息的样本。

虽然当然可以为每个PBRMaterial指定一个单独的环境贴图,但通常在Scene上设置它更容易,我们将在下一节中更详细地了解如何完成这项任务,即与 PBR 材料和 IBL 场景一起工作。一个可能的环境纹理和材料的反射纹理可能不同的特定用例是汽车的倒车镜,它不仅显示了环境,还显示了场景中物体的反射——这是 IBL 和环境光照所无法做到的。

在这个场景中,一个常见的解决方案是使用Reflection Probe动态生成一个反射纹理。这是一种Render Target Texture(它本身是一种程序纹理),可以从指定位置的角度使用一系列渲染目标来跟踪,从而提供更新的环境贴图。Babylon.js 文档中包含了更多关于如何使用反射探针的详细信息:doc.babylonjs.com/divingDeeper/materials/using/reflectionTexture#dynamic-environment-maps-rendertargettexture-and-friends

在本节中,我们介绍了一系列新的概念,例如BRDF以及一些与模拟光照相关的参数和术语,从理解漫反射、镜面(反照率)、发射和环境光照源之间的区别开始。这为我们探索具有重点的 Babylon.js PBRMaterial这一材料概念奠定了基础。PBRMaterial实现了一种称为 PBR 的技术,它使用环境提供的照明信息,结合一系列材料属性,以逼真地模拟光线在粗糙和光滑、平滑和暗淡的表面上的行为。一旦我们了解了材料和光照,我们就探讨了如何使用IBL进一步增强渲染场景的真实感。

在下一节中,我们将理论付诸实践,了解使用之前讨论的概念所需的不同资产。在了解一些涉及的资产类型以及与之相关的文件和图像格式之后,我们将探讨制作这些资产所需的工具以及如何使用它们。

这是一个复杂的话题,所以如果你觉得还不是一切都很有意义,那么花点时间看看 Babylon.js 文档中列出的 Playground 示例是完全可以的。如果这一节对你来说主要是复习,那么你可能对这一章前面“推荐阅读”部分中链接的一些更高级主题感兴趣。

与 PBR 材料和 IBL 场景一起工作

StandardMaterial在能够使用各种类型的资产方面非常宽容。它不介意纹理是每像素 8 位、16 位还是 3 位,也不介意是 JPEG、GIF 还是 PNG – 它会用它来绘制网格。虽然这在PBRMaterial中也大致正确,因为它是一个健壮的组件,能够处理广泛的输入,但任何PBRMaterial的渲染外观对不足或不正确格式的纹理数据非常敏感。我们将探讨 Babylon.js PBR 实现的具体要求,以及有助于创建符合这些规范的资产的工具。稍后,我们将讨论启发式方法——一套指南——如何决定将哪些资产和值放入哪些属性中,以实现材料特定的外观。让我们首先检查一些表示数字图像的方法。

图像格式和文件类型

位图是最简单的图像类型。名字已经说明了一切——它是一个按顺序排列的值数组(或映射),每个值代表图像中单个像素的单个通道(红色、绿色或蓝色)。当图像解压缩到(通常是 GPU)RAM 中时,结果是位图。由于每个像素都映射到内存中的不同位置,因此从图像中的任意位置查找值非常快。然而,当在磁盘上存储图像时,目标是优化文件大小,以牺牲计算速度为代价。

能够支持 HDR 图像的文件格式只有少数几种。两种流行的原生 HDRI 格式是HDREXR。RAW 图像格式尽可能接近数字相机的传感器捕获像素值,这意味着可能需要在不同的设备上进行校准以获得一致的结果。某些图像类型,如 TIFF,可以作为其他图像的容器,同样,一些格式提供了广泛的选择,例如DDS。尽管 GIF 和 JPEG 很受欢迎,但它们并不是能够表示HDRI的格式,尽管它们仍然能够显示看似 HDR 的图像。这是通过称为色调映射的过程实现的,我们将在探讨完为什么 JPEG 不是 HDRI 之后讨论它。为此,我们将涵盖位深度和动态范围。

位深度和动态范围

当思考图形及其显示方式时,将其分解为基本概念是有用的。图像的每个像素都有红色、绿色和蓝色颜色通道的值(某些图像可能还有一个用于透明度的额外 alpha 通道)。

如果我们使用一个字节(8 位)来表示每个通道,那么每个像素就有 24 或 32 位,这取决于是否存在专门的 alpha 通道。每个颜色通道只能取 0 到 255 之间的值,总共 65,536 种可能的独特颜色在颜色空间中。这听起来颜色很多——确实如此——但它远远低于人眼所能分辨的颜色范围。更重要的是,在显示技术的背景下,它无法在没有色调映射的情况下正确地表示 HDR。色调映射是将无限缩小到有限的过程,通过离散的步骤进行。

在零和一之间是无穷大,或者如果你使用 32 位浮点数来表示颜色通道,那么它就足够接近无穷大了。另一方面,1:256 这个更小且可计数的比例是 8 位通道中可能的全动态范围。要成为高动态范围图像HDRI),图像需要能够使用 16 位或 32 位浮点数来表示红色、绿色和蓝色颜色通道。这总共是每像素 48/96 位,理论上可以实现 1:无穷大的动态范围。然而,实际上,这可能会占用相当大的空间——一个 4K 图像大约有 830 万个像素,以 96 bpp 计算,原始大小约为 800 MB!

但不仅如此。用于 PBR 和 IBL 的图像需要具有所谓的米普贴图,这些米普贴图可以在加载时生成,也可以预先烘焙到图像文件中。米普贴图是主纹理的较低分辨率版本,其使用方式类似于网格的细节级别LOD),其中远离的对象使用更详细的纹理进行渲染,从而节省内存和渲染时间。就像自动 LOD 对网格起作用一样,Babylon.js 可以在加载纹理时生成米普贴图。

注意

几乎所有购买过现代 AAA 游戏下载版的人都知道,这些高质量的纹理伴随着高带宽和高磁盘使用成本。使命召唤系列的最后一部作品,现代战争,超过 175 GB!如果资产在压缩后都是这个大小,那么考虑一下纹理的大小,这将回答在游戏过程中那些 GB 的 RAM 都在做什么的问题。

一个艺术资产在图像查看器中看起来不错固然重要,但它的大小和格式必须正确,以确保它在整个图像中包含或保留完整的颜色和亮度范围。幸运的是,仅 Babylon.js 生态系统内就有一些可用的工具可以帮助完成这项任务。

用于 PBR 和 IBL 的资产的使用和创建

由于使用 PBR 和 IBL 消费和使用资产的方式多种多样,很难确定在哪里使用什么,以及为什么。专门为特定项目创建的资产最有可能带来最佳的整体效果,但同时也存在需要自己创建资产或购买或委托他人创建资产的技能和知识,或者需要购买资产的财务资源的固有困难。无论以何种方式获取纹理或其他资产,都需要更多决策来评估其适合性和与 Babylon.js 的兼容性。以下图表说明了您可以使用的高级别决策过程来评估给定的纹理资产,称为 IBL 中的“我”:

![图 10.6 – 使用纹理和 IBL/PBR 的高级别评估流程。这是一个定性评估,而不是定量评估,因此其他因素,如纹理分辨率,仍然很重要进行评估图片

图 10.6 – 使用纹理和 IBL/PBR 的高级别评估流程。这是一个定性评估,而不是定量评估,因此其他因素,如纹理分辨率,仍然很重要进行评估

让我们逐一概述这些节点的亮点。请记住,资产是否适合与 PBR 和 IBL 一起使用并不一定意味着它是有用的。同时,考虑资产将被查看的上下文也很重要;一个仅在相机远处渲染的高分辨率纹理有什么好处?

获取资产

这一步是较为复杂且难以定义的步骤之一。获取适当的 3D 资产的过程将因几个基本因素而有很大差异:

  • 获取专业图形艺术家的服务(以及他们工作所需的时间!)

  • 从供应商处购买/获取资产包

  • 自我创作能力 – 例如,自己制作所有内容

  • 从多种来源混合组装资产

重要提示

无论您选择哪种方法或路径,在决定包含艺术资产之前,务必确保您有明确和免费的许可和权利使用该艺术资产。

如果您有资源,最好是聘请专业艺术家或艺术家团队,但这些人不免费绘画。准备好为他们的工作支付报酬。购买一套预制的资产通常几乎与定制资产一样好,但它们的优势在于几乎可以立即部署,代价是缺乏灵活性——任何更改或文件转换都由您负责。除非您是多面手——也就是说,在多个领域(如 Babylon.js 著名的创建者 Deltakosh)专业熟练,否则通常将时间和精力用于对资产的轻微编辑会更好。

重要提示

不要忽视浏览 Babylon.js 资产库——它包含许多非常实用的“基础”纹理和网格资产,这些资产已经准备好供您将其放入项目中!从 v5 版本开始,资产管理员是一个工具,可以直接将 BJS 资产的引用注入到游乐场中。您可以在doc.babylonjs.com/toolsAndResources/assetLibrarian了解更多信息。

最后一个选项,按需资产合并,是其他三个选项的折中方案,因此基本上提供了每个选项的大部分缺点和少数优点。这个方法唯一的优势是其灵活性,这是无法被超越的。这是一种最低的共同分母,采用这种方法需要小心和努力,以提供应用程序的一致外观和感觉。作为这种方法的一个推论,总是存在通过代码程序设置材质属性的“逃生门”,而不使用纹理。

环境纹理、转换和压缩

要在 Babylon.js 中用作 PBR 的 HDRI,环境纹理必须以 HDR 格式。如果不是,则需要将其转换为 HDR 或 DDS 格式,如果它可以存储每个颜色通道的 16 位或 32 位浮点表示。从那时起,有几个选项,但从场景质量的角度来看,重要的是要确保图像已经准备为单个等经线或一系列立方体贴图图像。

在将世界平面的地图适应到球体上的过程中,环境贴图代表周围环境的球形或全景视图。作为使用球体的替代方案,也可以以相同的方式使用立方体,每个面的投影图像展开成六个单独的图像或图像部分。有关立方体贴图的更多信息,请参阅doc.babylonjs.com/divingDeeper/environment/skybox#about-skyboxes

重要提示

当环境纹理是立方体贴图时,HDR 渲染不可用,可能会出现接缝或其他视觉伪影。

如在位深度和动态范围部分所述,存储所有这些浮点图像数据需要很大的空间,这在处理基于 Web 的应用程序时可能非常重要。在应用程序中使用 DDS 或 HDR 图像(www.babylonjs.com/tools/ibl/)的最简单方法是在Babylon.js IBL 工具中使用。使用前面描述的作为等角图的准备好的图像将给出最佳结果,但这不是必需的。将你想要使用的图像文件拖放到页面中央面板,稍等片刻——你可能不会立即看到任何变化,因为处理图像可能需要一些时间,这取决于其大小和类型。一旦工具完成,将发生两件事:首先,图像将出现在页面上,准备就绪作为预览。其次,一个.env文件将被下载到你的电脑上。这个文件是源图像的压缩和预处理版本,通过文件大小的快速比较将显示出源文件和输出文件之间的显著差异——30 MB 可以轻松压缩到几百 KB!你可以在doc.babylonjs.com/divingDeeper/materials/using/HDREnvironment#what-is-a-env-tech-deep-dive了解更多关于 rle-RGBE 格式和允许实现这种压缩的额外预计算数据。

分配给材质纹理槽

图 10.6的非环境纹理部分展示了 Babylon.js PBRMaterial中一些更常用的纹理通道,以及在使用时需要注意的一些事项。例如,当使用纹理来定义材质的金属和/或粗糙度参数时,可能需要指定哪个颜色通道(R、G 或 B)包含相关数据值。

PBRMaterial的一些属性扩展成一组新的属性,其中许多可以接受纹理作为指定值的手段。清漆、次表面和细节图(等等)各自都有自己的一套参数和纹理,可以用来提高最终输出的质量,从而产生一个令人眼花缭乱的配置组合。不必担心试图理解并可视化每一个选项及其工作原理——在下一章中,我们将学习节点材质编辑器NME)如何帮助理解这些选项。

在本节中,我们基于前几节建立的理论基础,学习 HDR 图像是如何在数字上表示和存储的。HDR 图像位于线性色彩空间(与伽玛sRGB空间相对——也就是说,每个颜色通道使用多少位以及位的使用方式)中,并且每个颜色至少使用 16 位浮点数。大多数情况下,在标准动态范围图像中,线性颜色位于[0,1]的范围内。然而,HDRI 的范围在实用层面上可以从[0, ∞]变化。例如,一个包括晴朗天空中的太阳的场景的 HDRI 可能具有[0, 150000]的范围!

存储 HDR 图像的常用文件格式有几个,但与 Babylon.js 资产一起使用支持最好的是 HDR 和 DDS。环境纹理需要布局在球面表面的矩形投影上——即等角投影——或者作为一系列六个图像的立方体贴图。Babylon.js IBL 工具可以用来查看放置在其上的图像的细微细节,但更重要的是,它可以转换和压缩 HDR 或 DDS 图像,使其在网页上使用时的大小更加易于管理:ENV 文件格式。

大多数计算机显示器和打印技术无法渲染如此广泛的值范围——实际上,任何能够准确表示太阳亮度的显示器,对于观看者来说都会是一个极其热辣的体验。要在非 HDR 显示器上准确渲染 HDR 图像,必须将颜色值重新映射回[0,1]的范围。这个过程被称为色调映射,并且是完成场景展示的重要步骤之一,被称为后期处理

色调映射和基本后期处理

虽然本节在色调映射和后期处理之间分为几个独立的子节,但从技术上来说,色调映射是一种后期处理。在本章的背景下,这是一个足够重要的主题,值得占用一些篇幅来解释。

后期处理是一个熟悉的概念,但可能用了一种不熟悉的语言。当你将猫耳朵叠加到 FaceTime、Zoom 或 Teams 通话中时,你正在使用后期处理。如果你选择 Instagram 滤镜,你也在使用后期处理。当你想在 TikTok 中给自己添加一个酷炫的运动模糊效果时,你也在使用后期处理。Babylon.js 内置了多种不同的内置效果,既有细微的也有明显的,为了避免你记住并创建最常见的后期处理效果,还有一个默认渲染管线,它将所有基本功能打包在一起,以即插即用的方式提供。

色调映射

正如我们在上一节中讨论的,将 HDR 图像渲染到非 HDR 显示介质上需要通过色调映射的过程,将每个像素颜色的值从可能的无穷大范围重新映射到一个明确的有限范围内。有几种不同的算法和方法可以实现这一点,但无论具体细节如何,任何色调映射都不可避免地需要做出妥协。

我们可以说,我们有一组数字 – [0.1, 0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.0, 2.5, 10] – 我们需要将其重新映射到零和一之间的范围。以下是一个图表,显示了使用最简单的色调映射技术前后该系列的变化:

![图 10.7 – 预色调映射和后色调映射结合辐射值与 HDR 值的图表。这种映射并不能完美地捕捉原始值的动态范围图片

图 10.7 – 预色调映射和后色调映射结合辐射值与 HDR 值的图表。这种映射并不能完美地捕捉原始值的动态范围

前一个图表中的虚线显示了如何将实线所代表的范围压缩到图表的零和一之间。理想的映射应该尽可能地模仿实线 – 但在这个简单的线性映射中并非如此。这对于许多应用来说是足够的,但其他映射函数可以使我们更接近匹配曲线。伽玛校正函数使用两个常数,A 和γ,这些常数必须单独计算或手动确定,以便以更接近原始曲线的方式映射值:

![图 10.8 – 使用伽玛校正的色调映射产生几乎与原始图像无法区分的曲线。两个常数的值必须分别确定图片

图 10.8 – 使用伽玛校正的色调映射产生几乎与原始图像无法区分的曲线。两个常数的值必须分别确定

当提供适当的常数 A 和伽玛的值时,虚线与原始 HDR 亮度曲线完美重叠。这种技术的缺点是,这些常数可能会因显示设备、操作系统和其他潜在变量而有所不同。幸运的是,Babylon.js 在它的内置图像处理和后处理功能中为你完成了色调映射的所有工作。

后处理和渲染管线

假设阅读这篇文章的任何人熟悉实时相机滤镜的概念。只需切换一下开关,你的照片就会变成老式照片,再切换一下,它就会变成漫画书海报,这一切都在实时进行。如果你曾经好奇过这类事物是如何工作的,那么从后处理开始是个不错的选择!将后处理想象成是实时 Photoshop,用于你的场景。在游戏中,一些更明显的后处理包括雨或雪的落下、屏幕震动以及始终经典的“醉酒踉跄”效果。

在 Babylon.js 中,有几种不同的方式来实现、导入和使用后处理,但所有后处理效果的工作方式都是一样的:它们从纹理开始。这种纹理在帧的开始就像一个装裱的空白画布;空白处的颜色是场景的清除颜色。如果数字渲染过程中的每个阶段都像是在画布上手动渲染油漆的步骤,那么我们感兴趣的帧渲染管道中的时间点就是油漆已经铺在画布上但尚未干燥和固定的部分。这个纹理是将场景的所有几何形状转换成相对于摄像机的位置,然后转换到 2D 屏幕空间的结果。后处理处理的是这个纹理的单独像素,而不是场景的几何形状。Babylon.js 有几个现成的PostProcessRenderingPipelinesPostProcesses,可以通过一行或两行代码添加。在下一章中,我们将探讨如何创建后处理以及实现这一点的两种不同方法。让我们不要让下一章抢了本章的风头,继续探讨更多内置的后处理功能,比如体积光散射——也就是“上帝之光”。

添加体积光散射后处理效果

让我们看看在路线规划屏幕上使用内置后处理的一个简单而具体的例子。当一个强烈的光源位于物体和观察者后面时,以斜角击中物体的光线可能会散射,从而产生一种独特的眩光效果,使太阳看起来更加明亮!这种效果被称为体积光散射(也称为“上帝之光”),使用起来非常简单,你甚至不需要知道它是如何工作的。以下是需要的两行代码(为了清晰起见,分多行显示):


var godrays = new VolumetricLightScatteringPostProcess(
    'godrays', 1.0, this.scene.activeCamera,
    this.mesh, 100, Texture.BILINEAR_SAMPLINGMODE,
    this.scene.getEngine(), false, this.scene);
godrays._volumetricLightScatteringRTT.renderParticles =
  true;

这些行代码被添加到Star.mesh球体上,使用活动相机进行渲染。最后一行设置了一个内部属性,指示在后期处理中使用的内部渲染目标纹理将粒子渲染到效果中。

以下截图展示了应用此后处理的效果。相当大的改进:

![图 10.9 – 通过后处理添加的体积光散射效果,给路线规划屏幕上的明亮阳光造成了相机眩光的效果]

图 10.09

图 10.9 – 通过后处理添加的体积光散射效果,给路线规划屏幕上明亮的太阳造成的相机眩光感

Babylon.js 除了 VLSPP 之外,还提供了多种开箱即用的后处理效果,其中大部分同样易于使用。如果这些都不符合您的需求,您始终可以选择以多种形式创建自己的后处理效果,我们将在下一章中介绍。就当前主题而言,我们将探讨如何通过默认渲染管线后处理,用一小段代码就能获得大量渲染质量提升。

默认渲染管线

与前文相比,这个说法并不那么冗长,但默认渲染管线通过其可爱且实用的效果组合来弥补这一点。一个勤奋浏览视频游戏图形设置菜单的浏览器(谁不是呢?)会认出构成这个渲染管线的许多后处理效果。管线中包括与材质级别上可用的相同图像处理效果,但还有其他一些,如 Bloom、胶片颗粒、FXAA 等!每个效果都提供了合理的默认设置,但了解这些设置很重要,以便可以根据具体情况进行调整。BJS 游乐场在playground.babylonjs.com/#Y3C0HQ#146是来自 Babylon.js 文档页面的默认渲染管线的完整示例——它有一个交互式用户界面,允许您快速更改参数或启用/禁用后处理并查看其效果。玩一玩这个示例,以了解不同类型的效果及其设置如何仅通过少量调整就能完全改变场景的外观和感觉!使用这个渲染管线是获得高质量图像的基本筹码;这是一个良好的起点。

应用或游戏的外观和感觉的演变不可避免地包括添加其他独特的后期处理和效果组合。这就是使游戏或应用程序与其他游戏或应用程序区别开来的原因,这也是艺术和美学有很多空间的地方。在本节中,我们讨论了色调映射如何将高动态范围图像或场景“转换”为显示器能够渲染的范围。由于在执行色调映射时需要做出一些妥协,因此存在不同类型的色调映射算法,这导致了输出中不同的视觉差异。我们学习了色调映射如何作为基于材料或基于像素的图像处理效果的一部分融入后期处理管道。这些效果具有共同的配置,并包括除色调映射之外的一些调整。其他后期处理效果包含在默认渲染管道中的图像处理效果中。这些效果包括 FXAA、光晕、胶片颗粒等。

摘要

这章可能感觉要么非常长,要么非常短,要么非常无聊,这取决于你现有的知识和经验。现实世界中光的行为极其复杂,因此在场景中模拟它时,有必要对该行为进行简化和假设。

从光源到目标材料的射线旅行中,光是通过某种双向反射分布函数BRDF)的实现来建模的。此函数计算从光源在给定输入点和角度处的(非)辐射或亮度。该函数有一组术语,每个术语都在单独的函数中计算,然后组合以提供结果。

漫反射项(也称为反照率)负责解释从材料表面均匀散射的光,有点像点光源均匀地向所有方向投射光。镜面反射是指从材料直接反射到相机或观察者那里的光,它具有明亮、可能尖锐的轮廓。镜面反射光的具体贡献很大程度上取决于材料的特性;金属、光滑的表面比粗糙、非金属的表面更能一致地反射光。发射光也称为自发光,因为它没有光源作为起点,并且它不会影响其他材料的照明。最后,环境光照是一个总称,用于任何从其源头间接到达相机的光照类型。大气散射是环境光照源的一个例子。

描述光线在网格上行为的不同质量和属性被分组到称为材质的组件中。材质实现了各种关键功能,这些功能被用于 BRDF(双向反射分布函数)。Babylon.js 的StandardMaterial满足大多数基本的场景需求,这些需求不需要进行逼真的渲染,而PBRMaterial提供了一个基于物理的渲染PBR)BRDF 实现,该实现紧密模拟了不同表面类型(从粗糙到光滑,从光亮到暗淡)的真实世界行为。

为了使 PBR(基于物理的渲染)有效地工作,场景的环境需要提供必要的光照信息。基于图像的照明IBL)是一种技术,在渲染时对一种特殊类型的图像进行采样,以提供关于场景在当前相机位置和视角下的光照信息。使这种图像类型特殊的是,它使用 16 位或 32 位浮点数来表示每个颜色通道(红色、绿色、蓝色,有时还有 Alpha)的图像数据。用更多的位来表示一个数字意味着,从实用角度来看,场景中最亮和最暗区域之间的亮度比或范围可以有效地是无限的。这正是定义并允许捕获和存储 HDR 照片或图像的原因。

这种纹理被称为环境纹理,但在天空盒的上下文中,它以反射纹理的形式出现;两者使用相同的纹理执行相同的任务,但采用不同的方法。静态场景的环境或反射纹理可以通过几种方式预先生成。它们可以使用 Blender 或 Maya 等 3DCC 工具从现有场景中“烘焙”出来,它们可以被配置适当的相机从渲染输出中捕获,或者它们可以使用 GIMP 或 Photoshop 等工具从现有图像中手动准备。这些方法将无法考虑到场景的网格及其属性,因此可以使用如反射探针这样的动态方法来实时生成反射纹理。

一旦你获得了 HDR 图像,关于下一步要做什么有几个选择。DDS 和 HDR 图像的文件大小可能相当大,因此,Babylon.js IBL 工具是转换图像为 ENV 格式以在 Babylon.js 场景中使用的地方。在PBRMaterial上有几个不同的参数和纹理槽可供分配,但通过 BJS 文档、Playground 示例,当然还有这本书,你应该有足够的装备来探索它们!

一旦场景在 GPU 上渲染完成,并不一定立即传递到显示设备。后处理以一系列管道的形式被采用,允许场景相机的输出依次以不同的方式进行处理。内置的图像处理提供了许多常见的图像校正和调整,这些你可能从智能手机的图片编辑软件中熟悉,但还有其他后处理可供选择,这些后处理只受限于 RAM 和想象力。

当与 PBR/HDR 场景一起工作时,最重要的后处理之一是色调映射。这是一个数学运算,将无法由大多数显示设备表示的高动态范围转换为标准范围的颜色和亮度。因为这涉及到将可能无限(或至少非常大)的空间压缩到一个更有限的空间,所以会有一些保真度和准确性的损失。因此,有不同算法用于执行此映射,这些算法强调亮度或颜色曲线的不同区域。

在下一章中,我们将深入探讨一个硬核主题——着色器。Babylon.js 有许多方式允许开发者编写、管理和应用标准 GLSL 代码。这意味着什么以及着色器是什么将在稍后定义,所以系好安全带——下一章将是一次疯狂的旅程!

扩展主题

没有比尝试从未知领域雕刻熟悉的东西更好的方式来学习新事物了。同时,确定最佳切割位置和内容可能很困难。以下是一些想法、练习和例子,可能为你提供一个良好的起点:

  • 使用你现实世界中的一个例子,创建该例子的环境的逼真再现:

    • 使用你的智能手机或设备上的相机捕捉周围立方体或球面贴图纹理,以尽可能高的质量。

    • 将图片导入到图像编辑工具中,调整图片以使其具有高动态范围(确保以 32 位 RGBA 格式保存!)。

    • 将 HDR 图像导出为 DDS 格式,然后使用 BJS 纹理工具将其转换为 ENV 文件。

    • 创建一个使用你环境的 PG,并通过将一些网格放入环境中来测试它。确保配置并给他们一个PBR 材质

  • 天空盒不必与场景的环境(反射)纹理共享相同的纹理。通过修改 Space-Truckers 路线规划场景以使用高质量的压缩 ENV 文件的天空盒来演示这一点。

  • 使用静态背景环境进行反射并不意味着场景不能即时创建一个对场景动态的反射纹理。使驾驶阶段的路线网格闪亮并具有反射性,然后使用反射探头(有关如何使用它们,请参阅doc.babylonjs.com/divingDeeper/environment/reflectionProbes)使 Space-Road 的表面反射卡车经过时的图像。

  • 一些系统可以处理后期处理增加的负载,但其他系统(尤其是移动设备)可能无法维持一个令人满意的帧率。允许 Space-Truckers 的最终用户启用后期处理并调整变量。稍后,这可以连接到设置对话框,或者可能链接到场景优化(有关更多详细信息,请参阅第十二章测量和优化性能)。

第三部分:走得更远

书的最后一部分是我们将开发的游戏从粗糙的演示转变为完整的应用程序。作为额外奖励,最后一章包含了一系列未在其他文本中讨论的不同主题。嘉宾贡献者为 Babylon.js 世界中的其他感兴趣的主题提供了额外的背景和细节。

本节包含以下章节:

  • 第十一章探索着色器表面

  • 第十二章测量和优化性能

  • 第十三章将应用程序转换为 PWA

  • 第十四章扩展主题,扩展

第十一章:探索着色器的表面

泰坦尼克号前船长爱德华·J·史密斯无疑会是第一个承认冰山可见表面只是巨大物体一小部分的船长。当用作类比时,短语“冰山一角”通常被理解为可见的并不代表整个事物。

注意

上述对爱德华·史密斯船长的奇怪具体提及,是 Trivia Night(知识之夜)上一个极好的随机事实。

同样,探索一个主题的表面会唤起孩子们试图挖洞到达地球另一侧的意象。与一个按比例的地球仪并置,它暗示了挖掘者任务的浩大。这绝不是减少了孩子们从他们荒唐的冒险中获得的乐趣,但通过描绘地壳、地幔和核心的不同层,它承认一项严肃的尝试不仅是在更大规模上做现有的事情。

前一段落几乎可以直接出自一本自助书籍,它试图用类比重重打击读者的头脑,但它确实准确地描述了本章的主题。从广义上讲,本章的主题是着色器和 GPU 编程,重点是工具以及如何使用这些工具来服务于这一主题。这使我们面临一个像之前遇到的问题一样的问题:当我们查看输入和控制系统时(第五章**,添加场景和输入处理)。如您所忆,问题在于要获得对这一主题的深入理解所需的材料量,需要一本自己的书来适当涵盖它!

与之前的情况一样,我们将尽可能涵盖基础知识,同时为你在学习这一主题时决定采取的任何下一步学习打下基础。这意味着可能会有一些东西没有得到太多空间,但(希望)由可用的优秀链接和资源来弥补。

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

  • 理解着色器概念

  • 在 Babylon.js 中编写和使用着色器

  • 使用节点材质编辑器进行着色器编程

这只是可能涵盖主题的一小部分,但到本章结束时,你将了解足够多的知识,可以立即在当前项目中发挥作用,同时也有足够的根基来了解你下一步学习的方向。

技术要求

本章的技术要求与之前相同;然而,有一些主题和领域可能有用,需要复习或补充:

  • 向量数学运算:这包括加法、减法、点积、叉积和其他运算。你不需要执行计算或记住任何方程,但了解它们的含义或目的(例如,你可以使用向量减法来找到两个对象之间的方向)是使知识有用的关键。

  • 函数图像:Windows 和 macOS 都内置或提供免费的可用图形计算器,可以绘制输入的方程图。这在理解不同输入下片段代码的输出非常有用。图形绘制就像使用 TI-89 一样!仅在线选项是 Desmos 图形计算器,位于 www.desmos.com/calculator

理解着色器概念

在独立 GPU 变得普遍之前的日子里,将像素绘制到屏幕上的方式与今天大不相同。现在的孩子们根本不知道可编程着色器让事情变得有多好!当时,你会直接将像素颜色值写入内存中的一个缓冲区,这个缓冲区将成为发送到显示器的下一帧。专用图形处理器作为附加组件的出现和普及始于 1990 年代末,这彻底改变了整个行业。显示像素的访问被抽象为两个主要的 应用程序编程接口API):DirectX 和 OpenGL。这些接口的演变有着极其丰富的历史,但这本书不是关于图形硬件接口及其历史的书籍——这是一本关于当前 3D 图形开发的书籍,所以我们只需将细节留给那些专著,并简要总结它们。

为了避免开发者和最终用户软件需要支持每种型号和品牌的显卡,开发了一套 API,硬件制造商可以据此实现。两个竞争标准应运而生——DirectX 和 OpenGL——在接下来的十年左右的时间里,随着每个标准试图适应和改变快速发展的图形技术格局,随之而来的是一系列的戏剧性事件。我们将通过查看着色器本身来结束本节,这需要我们理解着色器如何与计算机硬件和软件的其余部分相关联。然而,在我们能够理解这一方面之前,我们需要了解为什么一开始就需要做出这些类型的区分。

GPU 与 CPU 的区别

图形处理器并不是以与常规 CPU 相同的方式构建的。在基本的硬件层面上,图形处理器是围绕快速执行某些类型任务而构建的。这意味着应用程序需要包含专门的代码,以充分利用这些能力。与为网站的前端或后端编写的代码不同,那里的代码是顺序执行的,一条指令接着另一条指令,GPU 希望尽可能并行执行尽可能多的操作。然而,这并不是描述 GPU 编程方式与我们的先前经验和直觉不同的一个很有启发性的方式。一个更好的类比可能是考虑一位画家如何将一幅大型壁画绘制到墙上,正如图 11.1所示。

就像人们熟悉的消费级打印程序一样,大多数打印都是由称为光栅化的过程完成的 – 打印头扫描纸张,在特定的时间和地点喷洒特定颜色的墨水,并将其放置在打印颜色图案的对应位置。这样,图片就是从一角到另一角,逐行、逐像素地逐渐构建起来的。我们的画家以类似的方式工作,从画作的某一部分开始,一块一块地构建壁画:

![图 11.1 – 传统计算的类比是一位画家自己完成整个肖像,这类似于 CPU 处理指令的方式图片

图 11.1 – 传统计算的类比是一位画家自己完成整个肖像,这类似于 CPU 处理指令的方式

与我们那位作为 CPU 的孤独画家相比,图形处理器放弃了光栅化过程,转而采用更分散的散弹射击方法来绘制图像。在这里,数千名画家都被分配去处理同一幅画的不同小部分。没有任何一个“画家”了解他们的同伴在做什么;他们只是按照指令来绘制他们的小部分画面:

![图 11.2 – 图形卡同时执行指令,每个“画家”只得到一小部分完整画布来工作,并且对其他画家一无所知图片

图 11.2 – 图形卡同时执行指令,每个“画家”只得到一小部分完整画布来工作,并且对其他画家一无所知

正是通过这种方式,图形卡可以处理每秒数亿次的计算,以驱动现代 3D 图形应用程序和游戏。正如前面的图表所暗示的,不是只有一个画家(处理器)有系统地铺下每一笔和每一层颜料,直到画面完成,而是一群画家各自执行相同的指令集,但数据完全特定于他们自己的画布部分。

着色器是 GPU 应用程序

应用程序如何利用这种处理方式?更重要的是,开发者如何编写利用这种大规模并行处理资源的代码?要全面且恰当地回答这些问题,需要(再次)一本完整的书。本章的“扩展主题”部分包含了几本这样的优秀著作!我们将历史背景、基本概念和艰难的数学留给更值得的声音,而将重点放在编写指令的更实际方面,这些指令以图形卡程序的形式,更常见地被称为着色器,交给那些成千上万的画家。

在图形渲染过程中,我们从场景中的各种数据开始,例如几何形状、光照和材质,并以屏幕上显示的帧结束。这个结果包括像素及其颜色,屏幕上每个显示点的像素/颜色组合。在场景和屏幕之间,有几个重要的步骤,但在高层次上,这是渲染管线:

![图 11.3 – 简化的渲染管线。以场景作为初始输入,依次执行的着色器程序将场景几何形状转换为像素位置,然后最终将像素位置转换为颜色]

![img/Figure_11.03_B17266.jpg]

图 11.3 – 简化的渲染管线。以场景作为初始输入,依次执行的着色器程序将场景几何形状转换为像素位置,然后最终将像素位置转换为颜色

管道中的每一步都接收来自前一步输出的输入。我们感兴趣的每个步骤的特定逻辑由一个单独的着色器程序处理和表示。然而,请记住,在这个上下文中,一个单独的着色器程序是一段将以大规模并行方式执行,与屏幕或场景的每个部分相关的数据作为输入,以及它们输出的处理等效物。

关于着色器

如前所述,着色器是一种在 GPU 上运行的可执行程序。着色器程序提供一组常量,或称为统一变量,这些变量包含着色器可以使用的输入数据。着色器还可以引用纹理作为采样输入 – 我们将在本章后面利用这一强大功能。一些常见的统一变量示例包括动画时间乘数、向量位置或颜色,以及其他对向着色器提供配置数据有用的数据。给定着色器的输出各不相同;对于一个顶点着色器,输出是在顶点级别从世界空间到屏幕空间的给定几何投影。片段着色器的输出则完全不同 – 它是一个像素颜色值。更高级的是WebGPU 计算着色器,其输出可以是任意的 – 本章后面我们将更详细地探讨这一点!

我们在这里将要处理的着色器程序类型分为两大类:顶点片段。尽管它们被作为独立的事物引入,但它们通常包含并定义在相同的着色器代码中。目前编写着色器程序使用的两种主要语言是:硬件光照和着色语言HLSL)和OpenGL 着色语言GLSL)。第一种,HLSL,被微软 DirectX 图形 API 使用。我们不会在 HLSL 上花费任何时间,因为WebGLWebGL2WebGPU(有一个注意事项;见以下注意)都使用第二种语言,GLSL。由于 Babylon.js 建立在 WebGL/2/GPU 平台上,因此我们将重点关注我们的简要概述中的 GLSL。

注意

WebGPU使用 GLSL 的一个变体,称为wGLSL,但由于 Babylon.js 非常注重保持向后兼容性,您可以选择使用 wGLSL 或继续用常规 GLSL 编写着色器——无论哪种方式,您都可以通过 Babylon.js 转换着色器代码的方式继续使用 WebGPU。有关 wGLSL 和 Babylon.js 转换功能的更多信息,请参阅doc.babylonjs.com/advanced_topics/webGPU/webGPUWGSL

HLSL 和 GLSL 在语法风格上与 C/C++编程语言家族相关,尽管 JavaScript 是一种更高级的语言,但对于熟悉它的人来说,应该有足够熟悉的概念,以便为学习 GLSL 打下良好的基础。从 JavaScript 开始,最重要的是记住,与 JS 不同,GLSL 是强类型的,并且不喜欢自己推断变量和此类数据的类型。还有其他需要注意的怪癖,例如,当浮点变量被设置为整数值时,需要给数字添加一个.后缀。这很好地过渡到更多地讨论着色器程序与其他软件程序的不同之处。

作为一类软件,着色器有几个独特的特性:

  • 它们是无状态的。因为一个特定的图形处理器(一个图形卡可能有数千或数百万个可用)可能在这一刻被分配渲染 Instagram 图片的任务,它同样可能在下一次渲染电子邮件或文本文档。着色器所需的所有数据,无论是纹理、常量还是统一变量,都必须在着色器内部定义或在运行时传递给它。

  • 没有访问共享状态或线程数据的能力——每个进程独立运行,执行时对其邻居一无所知。

  • 着色器代码是针对整个视图或屏幕空间编写的,但给每个实例提供的指令必须以这种方式制定,即每个实例都得到相同的方向,但产生所需的个别结果。

在前两个项目之间,第三个项目的产生是着色器获得令人难以置信的难度声誉的起源。就像所有事情一样,编写着色器代码需要练习。最终,随着练习的深入,它将变得越来越容易,你将能够轻松地进入着色器思维模式,解决越来越复杂和困难的问题;你很快就会想知道所有这些喧嚣究竟是为了什么!在下一节中,你将了解将自定义着色器融入项目的几种不同方法。再次提醒,如果这超出了你的舒适区,不要过于担心下一节——让它慢慢渗透。我们在这里讨论的概念将在我们学习如何使用 节点材质编辑器NME)来编写着色器代码时变得非常有用,而你则可以专注于你想要完成的工作。

在 Babylon.js 中编写和使用着色器

由于着色器是用纯文本定义的,因此在项目中存储和加载着色器有众多不同的方法。在学习了一些关于着色器代码结构的知识之后,我们将回顾一些实现这一目标的方法。创建您的自己的着色器CYOS)工具是 Babylon.js 操场(Babylon.js Playground)的着色器版本,并且是编写 Babylon.js 着色器代码的一种方式。访问 CYOS 网址 cyos.babylonjs.com 可以在左侧面板看到着色器代码,并在右侧实时预览输出结果:

Figure 11.4 – The Babylon.js Create Your Own Shader tool functions similarly to the BJS Playground

Figure 11.04_B17266.jpg

图 11.4 – Babylon.js 创建您的自己的着色器工具的功能与 BJS 操场类似

在前面的屏幕截图中,你可以看到在左侧面板中定义了顶点和片段着色器的着色器代码,而实时预览显示在右侧。可以从下拉菜单中选择起始模板,以及用于预览的不同网格。

就像操场一样,您可以将您的作品保存到片段服务器,或者下载一个包含嵌入模板 HTML 文件的着色器代码的 ZIP 文件。同样,就像 PG 一样,播放按钮会实时编译并运行您的着色器程序的结果。这就是工具的整体机制和用法。现在,让我们看看它是如何融入我们所学到的不同类型着色器的知识中的。

片段着色器和顶点着色器

让我们提醒自己,顶部的部分,如 constructor 函数等,着色器程序至少需要为 void main(void) 函数提供一个定义,因为这是由 GPU 执行的函数。在主函数之外,子程序或辅助函数通常被用来封装和隔离代码,就像你可能会对任何其他编写良好的代码所做的那样。着色器的输入在顶部指定,与其他声明一起。根据着色器的类型和定义方式,可能会有几个不同的任意声明,但始终提供的是 positionuv 属性声明。前者是 Vector3,而后者是 Vector2;两者都代表来自源网格几何形状的数据:

  • 顶点的局部空间 position 是相对于网格原点的坐标,而不是世界原点。

  • uv 属性是纹理坐标。之所以这样称呼,是为了避免与纹理空间之外的 xy 坐标混淆。

其他声明包括以下内容:

  • 一个称为 worldViewProjection 的统一四乘四矩阵,它包含将顶点位置从局部转换为世界,然后转换为视图(屏幕或二维)空间所需的变换。

  • 一个可变(可变或更改的引用类型变量)vUV。这是传递给片段着色器的一份数据(可选),对于从采样纹理中查找像素颜色非常重要。

顶点着色器的输出是一个 Vector3,以 gl_Position 变量的形式,必须在 main 函数结束之前在着色器中设置。它的值是通过将提供的矩阵变换应用于位置值(在应用任何自定义计算之后)来计算的。

重要提示

这里没有涵盖这些概念的大量细节,否则我们就无法涵盖其他主题的所有内容。然而,这些基础知识应该足以帮助你开始能够阅读和理解着色器代码,这是达到熟练的第一步!

CYOS 屏幕代码面板的下半部分是片段着色器所在的位置。片段着色器不是接收网格的顶点位置,而是接收以varying vUV形式存在的 2D 屏幕位置,并将颜色输出到gl_FragColor。这个颜色值代表了屏幕上当前像素的最终颜色。当使用着色器中的纹理时,textureSampler引用 GPU 内存中加载的纹理,使用纹理坐标vUV来查找颜色值。这些坐标可以由网格几何形状(在材料的情况下)、视图或屏幕坐标(用于后期处理或粒子)、某些计算过程(如过程式生成的数字艺术)或这三种技术的某种组合提供。更改标记为模板的下拉选择器,以查看更多如何使用着色器用于娱乐和盈利的示例!

计算着色器(新到 v5!)

可编程着色器的可用性展示了现代 GPU 在桌面应用程序中的强大功能。随着最新的WebGL2WebGPU标准越来越普遍地被主要网络浏览器供应商实现,这种功能现在也适用于网络应用程序。在着色器方面,WebGPU最大的特性是新一代旨在通用计算的着色器,被称为计算着色器

有关如何编写和使用WebGPU 计算着色器的特定文档,请访问doc.babylonjs.com/advanced_topics/shaders/computeShader。顶点和片段着色器在完成范围和范围上被有意限制,特别是当这些任务不直接涉及场景几何时。另一方面,计算着色器是一种运行更任意(尽管不是更少大规模并行)的计算和输出的方式。让我们看看计算着色器擅长解决的具体问题类型。

在这个场景中,我们想要模拟水侵蚀对地形的影响。例如,海洋潮汐围攻沙堡或长期风化作用下的山脉,当底层计算具有更高的分辨率时,这些情况会更准确——涉及的粒子越多,每个粒子就能代表整体流体体积越小的一部分。有几个因素使得这个场景非常适合使用计算着色器。在建模流体时,使用近似值来简化计算。如前所述,单个计算单元的数量和大小直接关系到整个模拟的准确性和性能。如果模拟是一幅壁画,其绘画速度和分辨率或细节取决于有多少“画家”被分配来处理壁画的绘制。计算着色器成为理想选择的原因是,顶点和片段着色器在可以更新多少、什么类型和哪些数据方面有更多的限制,而计算着色器能够写入输出(类似于纹理)而不直接显示在屏幕上。它们甚至使从 GPU 向 CPU 传递数据变得更加实用,尽管如果你能避免的话,这仍然不是一个好主意——向那个方向传递任何数据都将是一个缓慢的操作。此外,增加的计算能力使得更准确但计算密集型的计算成为可能。

这可能看起来不是什么大事,但实际上很重要。能够持续并引用计算着色器的输出和输入,可以带来巨大的实用性——就像 Inspector Gadget 和他的标志性口号。大喊“Go Go Gadget Compute shader!”然后任何事都可能发生!计算着色器的输出可以用来驱动地形高度图,计算矢量场的值,以及更多。

注意

查看 playground.babylonjs.com/?webgpu#C90R62#12 了解如何使用计算着色器通过高度图和动态地形模拟侵蚀。注意查询字符串中添加了 webgpu —— 运行 WebGPU 示例需要支持 WebGPU 的浏览器。截至 2022 年 4 月,只有 Chrome 和 Edge Canary 版本支持 WebGPU 功能。查看 github.com/gpuweb/gpuweb/wiki/Implementation-Status 了解基于 Chromium 的浏览器中最新实现支持和状态。

计算着色器需要WebGPU,并且伴随着大量的复杂性,但一些问题值得这种额外的复杂性。能够并行执行大量计算,计算着色器与顶点或片段着色器不同,因为它们可以写入纹理或其他存储缓冲区,从而读取和写入可以由渲染管线中的其他进程使用的值。尽管它在最终上升至广泛采用和取代WebGL2的过程中仍处于起步阶段,并且仅在主要网络浏览器中开始出现支持,WebGPU计算着色器是值得尽早熟悉的技术。

继续着色器代码之旅

如前所述,着色器这个主题足够广泛和复杂,足以成为一本书的主题,而不仅仅是章节。幸运的是,这样的书确实存在,其中最好的之一是Patricio Gonzalez VivoJen Lowe所著的《着色器之书》。它完全免费,可在thebookofshaders.com访问。《着色器之书》自称是“通过片段着色器的抽象和复杂宇宙的温和逐步指南”,并且描述与实际情况非常接近。正如它所说,这本书只关注片段着色器,但其更大的价值来自于它提供的着色器代码思维沉浸和实践。书中充满了自执行示例和练习,很快你就可以开始享受并有效地使用着色器了!

让我们通过这张实用的表格回顾一下不同类型的着色器及其用途:

Table_11.01_B17266.jpg

WebGL2 使得在网页浏览器中暴露着色器逻辑和 GPU 功能变得非常容易,因此在学习着色器的旅程中有很多工具和资源可以探索。也许你的经验更多在设计艺术方面,编写代码的想法可能让你感到害怕或以其他方式令人望而却步。也许简单地同时记住你想要达成的目标以及顶点和片段着色器语法的概念很困难。或者可能是你不确定如何编写特定的着色器效果,需要实验和探索以发现如何继续。所有这些原因,以及许多未列出的原因,都是仔细研究 Babylon.js 的一个旗舰功能——节点材质编辑器NME)的好理由。这就是我们在下一节将要做的。

使用节点材质编辑器进行着色器编程

由于在本书中多次被提及,NME 可能已经获得了几乎神话般的地位,成为一款生产力工具。它的即插即用、拖放特性使得几乎任何人都可以使用视觉块来组装着色器。它以无缝的方式与检查器集成,民主化了 GPU。它与 Playground 的简单部署提供了一个从复杂到实用的短跑道。NME 可能是自面包遇到黄油以来最好的事情。

所有这些陈述都是真实的,除了关于 NME 比面包和黄油更好的部分——这一点并不成立。它比面包和黄油更好,但仅仅略逊于单独的面包。这是一条薄薄的发酵线,但值得烘焙。抛开夸张的说法,NME 确实是 Babylon.js 工具箱中最强大,如果不是最强大的工具之一。

在本节中,我们将学习如何充分利用 NME。到结束时,你会发现“以节点思考”变得很容易!首先,我们将探索如何创建和应用NodeMaterial到网格上。接下来,我们将探索使用 NME 创建程序纹理。最后,我们将简要看看如何使用 NME 创建后处理。

使用 NME 构建地球材质

有时候,在学习新事物时,有一个具体的例子去努力实现,而这个例子就是最终的目标,这可能会很有帮助。其他时候,从最终目标的原子子集开始,而不是从成品例子开始,可能会更加启发人心。我们将要开始的目标——我们的第一个原子子集——将会很简单:创建一个新的 NodeMaterial,将纹理渲染到球体网格上。容易,对吧?

注意

与大多数类似形式的修辞问题不同,前一个问题答案是明确且响亮的“YES!”。如果之前没有明显地感觉到 Babylon.js 对易用性给予了极高的重视,那么回到这本书的前几章重新阅读(或者简单地第一次阅读)可能会有所帮助。没关系,没有人会因为你浏览或跳过而评判你!好吧,好吧。也许有一点。但不多。

在我们完成本章的过程中,我们将首先详细介绍如何使用 NME 完成各种任务的机制,但随着我们进入以下部分,我们将不得不从这些机械细节中退出来,以确保我们有足够的空间和时间来探讨更宏观的主题。一如既往,Babylon.js 文档是学习我们正在讨论的主题的绝佳地方,其中包括关于 NME 的丰富材料,包括组合游乐场和 NME 示例,用于各种任务。BJS 论坛是查看社区示例、征求反馈和提问的好地方。甚至还有一个专门用于 NME 示例的线程,forum.babylonjs.com/t/node-materials-examples!让我们开始吧。

NME 概述

导航到 nme.babylonjs.com;默认材质模式“空白画布”是初始要加载的节点图。分为四个功能区域和一个第五个预览面板,第一个面板 – 左侧的垂直列 – 包含可以放置到工作画布中间面板的不同节点的可搜索列表:

图 11.5 – 默认节点材质编辑器视图

图 11.5 – 默认节点材质编辑器视图

在前面的屏幕截图中,左侧面板包含节点列表。中间面板的工作画布是显示节点及其连接的地方,而右侧面板显示所选项目的上下文属性。注意渲染预览面板。

右侧面板显示可以修改的上下文属性列表,如果没有选择任何内容,则显示片段属性和选项。在属性面板的底部(如果最初不可见,请向下滚动)中藏有预览面板。立即将其弹出到一个自己的窗口中 – 能够立即看到更改的效果是这种类型开发成功的关键之一。底部的水井或沟槽,根据您如何称呼它,包含节点图着色器编译过程的控制台输出 – 如果最后一行是红色,则您的节点没有编译!

重要提示

有时,很难判断某个特定的更改是否非常微妙,或者它是否完全没有效果。始终确保检查您最新的控制台输出是否没有着色为红色或包含错误;否则,您可能会将损坏的节点与无效的更改混淆!

背景上下文

节点通过在两个相关节点上特定连接端口之间的线条连接。任何给定的节点都代表可以在一系列输入和输出上执行的操作,通过从前者拖动线条到后者来连接。节点及其连接的图遵循两个简单的规则,这些规则导致产生惊人的复杂行为。

首先,节点总是接受左侧连接器的输入,并在其右侧输出值。节点内部在输入和输出之间发生的事情是节点的私事。这个有趣的影响是,统一体、属性、常量或其他外部提供的数据没有输入连接器。相反,值通过代码、检查器或在设计时的属性面板中设置。相反,一些节点只包含输入而没有输出连接器。这些是节点着色器代码生成的端点;换句话说,它们代表适用着色器的返回值,例如片段着色器的颜色和顶点着色器的位置向量。由于它们是着色器计算的最后结果,它们必须始终是节点图中最后一个项目。

节点图的第二规则,并且遵循第一条规则,是只有连接到输出节点的节点才包含在生成的着色器代码中。记住,最终目标是生成用于顶点着色器的向量和用于片段的颜色。这意味着一个正确构建的节点图执行从开始到结束的顺序路径(通常是左到右),但由从终点到起点的路径定义(相反,或右到左)。

这种心理模型的不匹配(试着快速说五遍!)有时会使得可视化达到特定目标所需的步骤变得困难。这就是为什么在不需要对应用程序进行无关更改的情况下,使事物易于更改或添加变得重要的原因。在我们的案例中,我们将构建我们的工作结构,以便我们可以逐步构建一个超高细节和质量的地球材料。

回到 NME 窗口,将片段顶点输出节点拖到右边,为新添加的节点腾出空间。确保渲染预览设置为球体,并且如果你还没有这样做,将预览弹出到一个单独的窗口。现在,我们准备好实现我们的第一个微观目标——学习如何添加和使用纹理。

将纹理添加到材质中

在节点材质编辑器的材质模式默认配置中,顶点着色器的输入为mesh.positionWorld MatrixView Projection Matrix。将创建一系列texture以过滤列表并显示连接到纹理块uv连接器的mesh.uv节点。一般来说,这是一个一致的模式——如果节点块所需的输入不存在,它们将自动添加:

图 11.6 – 将纹理节点拖放到表面上也会添加 mesh.uv 值。这用于选择与网格顶点对应的纹理部分

图 11.06_B17266.jpg

图 11.6 – 将纹理节点拖动到表面也会添加 mesh.uv 值。这个值用于选择与网格顶点对应的纹理部分

前面的截图显示了设置,但也显示了我们的下一步:将 Texture 块的源输入端口拖动出来,如前一个截图所示。边做边整理是一个好习惯,所以如果节点尚未重命名,请选择节点并更改 baseTexture。通过引入纹理,我们已经实现了初始目标的一半。现在,我们需要将它绘制到预览网格上。

完成这一点非常简单,但记住涉及的底层机制是有价值的,因为它们很快就会变得重要。回想一下,顶点着色器传递了网格位置和对应于该顶点位置的 UV 纹理坐标,这些坐标将 UV 坐标传递到片段着色器。现在,我们需要采样纹理以设置片段着色器的最终颜色,我们通过拖动 FragmentOutput 节点的 rgb 输出的 rgb 输入来实现这一点。查看渲染预览;应该可以看到一个看起来熟悉的地球:

图 11.7 – 添加 baseTexture 并对其采样以用于片段输出的地球材料的渲染预览

图 11.7 – 添加 baseTexture 并对其采样以用于片段输出的地球材料的渲染预览

您可以将您的作品与 #YPNDB5 中的片段进行比较。如果您的预览与前面的截图不完全匹配,没关系——这只是一个预览,重要的是您可以看到球体上的纹理。我们的第一个任务已经完成!接下来是什么?现在是时候开始添加节点到我们的图中,这些节点将使用额外的纹理来为我们的地球材料添加更多细节。

混合云层

首先,在画布上添加另一个纹理节点和图像源。将它们命名为 cloudTexturecloudTextureSource,并将文件上传或链接到raw.githubusercontent.com/jelster/space-truckers/develop/assets/textures/2k_earth_clouds.jpg,以便将云纹理加载到设计表面。将云层叠加到基础纹理上的最简单方法是将每个纹理的颜色混合在一起,因此拖动一个 Mix Cloud and Base Textures。这突显了节点的一个重要属性,可能会让那些不熟悉这种编辑表面的人感到意外——类型匹配。

当节点最初添加到画布上时,输入和输出端口都是实心的红色,这表明输入和输出的类型尚未指定。在这种情况下,可能的类型可能包括 2、3 或 4 个元素的向量(或由相同数量的颜色组成的颜色),一个单一的标量数字,甚至是一个矩阵。块变成哪种类型取决于节点第一次连接的类型。将两个纹理的rgb端口连接到节点的单独输入,并将片段着色器的输出替换为混合云彩和基础纹理节点的输出以完成操作。云彩在渲染预览中可见,但它们有点淡,难以看清。

这可以通过在混合到基础纹理颜色之前对云彩颜色应用一个缩放因子来轻松修复。这种颜色的混合是一个非常常见的操作,尤其是在我们进入下一节,程序纹理和 NME时更是如此。添加一个浮点输入块,并将其命名为cloudBrightness。给它一个初始值约为1.25,然后将其用作新添加到画布上的缩放节点的输入因子。将这个节点命名为Scale Cloud Levels,并将另一个输入连接到cloudTexture节点的输出。Scale Cloud Levels节点的输出将替换Mix Cloud and Base Textures节点的输入:

图 11.8 – (a) 在添加云彩纹理和缩放因子到基础纹理颜色后,可以看到云彩在宁静的地球上方漂浮。(b) 混合和缩放云彩纹理与基础地球纹理的节点材质图。云彩亮度值可以设置为与云彩所需的外观和感觉相匹配的值

图 11.8 – (a) 在添加云彩纹理和缩放因子到基础纹理颜色后,可以看到云彩在宁静的地球上方漂浮。(b) 混合和缩放云彩纹理与基础地球纹理的节点材质图。云彩亮度值可以设置为与云彩所需的外观和感觉相匹配的值

结果应该看起来像第一张截图。如果它不是这样,请比较你的节点图与第二张截图或#YPNDB5#1 中的片段,看看它们之间可能有什么不同。一旦你对输出满意,保存片段为唯一的 URL 会是一个好主意。

注意

当你在使用 NME(NME 未知)处理某个项目时,最快的方式是将片段保存为 URL,直到你准备好下载定义文件并在你的项目中使用它。

我们的地球材质现在看起来相当不错,但有什么比无聊的静态纹理更酷的呢?一个动态纹理!让我们让云彩动起来,给我们的材质增添一些活力。

框架动画

当我们考虑动画时,很容易忘记场景中有很多不同的动画方法。其中最简单、最直接的方法是在时间上操作纹理坐标(uv 值)。此外,只需改变 u(或 X 轴)值,纹理就会以从东到西或从西到东的方式移动,这可能与地球同步卫星的外观相似!

scaleSceneTime 节点中搜索并添加 timeScaleFactor,我们可以在设计和运行时控制动画的精确速度。

重要提示

为什么我们使用时间而不是时间差?记住,着色器没有对过去事件的记忆。它们只处理传入的数据,而传入的数据在帧之间不会持续。因此,我们不是存储时间差并将其添加到 u 坐标,而是使用总场景时间和比例。

保持节点图可读性很重要,这不仅是为了你未来的自己,也是为了阅读它的人。做到这一点的一个很好的方法是将节点组织成可折叠的框架。将三个时间、比例和乘法节点靠近排列,然后按住 Shift 键,同时点击并拖动一个框围绕三个节点以创建一个框架:

图 11.9 – SceneTimeScaled 框架封装了将动画帧计数器暴露给节点图其余部分的逻辑。这是框架的展开形式

图 11.9 – SceneTimeScaled 框架封装了将动画帧计数器暴露给节点图其余部分的逻辑。这是框架的展开形式

框架是一种简单快捷的方法,可以将复杂的节点图变得更加易于管理,就像函数有助于将代码片段彼此分离和隔离一样。SceneTimeScaled 框架(或任何框架)甚至可以通过下载其 JSON 定义,然后使用 cloudTexture U 坐标在不同的节点材料之间重用。

与我们将云纹理与基础纹理混合时涉及的所有内容都是同一类型不同,我们需要能够改变 Vector2 值的单个元素。首先,我们需要将源向量拆分成其组成部分,然后在 x 组件中添加 scaledSceneTime 值。然后,我们将重新组合向量并将其连接到 cloudTexture UV 输入:

图 11.10 – 使用向量合并和向量拆分节点将 SceneTimeScaled 框架与 u (x) 纹理坐标连接

图 11.10 – 使用向量合并和向量拆分节点将 SceneTimeScaled 框架与 u (x) 纹理坐标连接

当连接完成后,打开渲染预览,惊叹于在宁静的蓝色海洋上缓慢移动的云层。如果你没有看到预期的结果,仔细检查输出,确保最后一行(当前行)不是一个错误或红色。如果需要,将你的 NME 图与 T7BG68#2 中的图进行比较,看看你做了什么不同。

NME 材料模式的内容远不止我们在短短几段文字中所取得的成就。在那短短的文字空间里,我们能够利用 NME 在 材料模式 下创建一个动画地球仪的渲染效果,并使用来自 NASA 的好心人提供的高分辨率纹理。将节点拖拽到画布上正变得越来越熟悉的活动,因为我们学会了如何将纹理混合到最终的片段颜色中,甚至可以动画化云层。通过练习和经验,使用内置的 时间 计数器和 向量分割器 以及 合并器 节点将变得像呼吸一样自然。然而,NME 不仅仅能将纹理映射到网格上,还有更多内容需要探讨。在下一节中,我们将移除 顶点着色器,专注于 片段着色器,以便我们最终了解我们的雷达程序纹理是如何构建的(更多信息请参阅 第九章计算和显示评分结果,了解这是如何融入游戏的)。

程序纹理和 NME

在大多数专业研究领域,一个特别困难的学科基础往往是早期就交给学生,以此来筛选或淘汰那些可能不太自信的学生。听起来很严厉,但让学生早期接触他们选择领域的现实可以是一种节省时间和精力的有价值方式,既对学生也有利于教师。本章的这一部分 不是 为了达到那种效果,因为假设你是出于选择和兴趣而在这里,这不是一个门槛练习,而是一个包容性的练习。NME 的 程序纹理 模式仍然包含顶点着色器的输出 – 并且它仍然需要在画布上存在 – 但我们的注意力将集中在片段着色器上,因为还有什么比 片段着色器 更适合同时处理一大堆任意像素呢?没有!在程序纹理的情况下,片段着色器输出到一个纹理缓冲区而不是屏幕缓冲区 – 这正是后处理所使用的。正如我们通过雷达纹理所看到的,这个纹理然后可以被应用到场景中的各种材质纹理槽位上进行渲染。

正是基于这种考虑和理念,我们应该接受雷达纹理的节点图 – 它不是用来吓跑那些可能不太自信的人的稻草人,而是用来支持和鼓励那些人的。这就是为什么我们将从最简单的节点图开始,这个节点图仍然传达了基本内容。之所以这样做,是因为虽然制作这个纹理涉及到相当多的移动部件,但一旦分解开来,每个部件都相对容易理解。请按照文本加载 NME 中的片段 XB8WRJ#13,并参考以下图表了解纹理每个具体组件的说明:

图 11.11 – 雷达程序纹理的组成部分

图 11.11 – 雷达程序纹理的组件

在前面的图中,三个圆圈(A)、两条交叉线(B)和扫过的线条(C)可以独立于彼此进行检查。三个同心圆将纹理绑定在一起,每个都是略带不同色调的浅蓝色。在中心相遇,每个都垂直于另一个,并且相对于向上方向呈 45 度角的是交叉线,色调为较深的蓝灰色。纹理的静态部分还包括扫过的线条,这是一块带有不透明度渐变的青色动画披萨切片。在下面的屏幕截图中,节点图完全折叠到其最大的组成部分。图 11.12中的每个元素在下面的屏幕截图中都有自己的框架:

![图 11.12 – 雷达程序纹理的节点图图片

图 11.12 – 雷达程序纹理的节点图

在将雷达程序纹理的主要元素分组后,节点图仍然复杂,但更容易理解。在 NME 中组织和命名元素非常重要!为了参考,NME 片段可以在#XB8WRJ#13 找到。

要检查着色器图的任何单个部分的详细信息,展开框架以查看组成该部分片段着色器逻辑的步骤。每个框架的输出略有不同。每个CircleShape框架在Add Circles框架中输出一个颜色值,正如其名称所暗示的,将颜色值相加。程序形状生成的关键元素是,不属于形状的像素将被分配一个清晰或空的颜色值。这就是为什么,如果你查看CircleShapeCrossMoving Line框架内部,你会找到导致输出设置为 0 到 1 之间[0…1]的值的条件节点和其他节点操作。0 的值意味着像素根本不是形状的一部分。任何其他值都表明像素最终颜色的相对亮度。

最终的颜色值是通过将每个元素定义的颜色(各种蓝色或白色色调)分别按该亮度因子缩放,然后将它们与所有其他元素的颜色输出相加得到的。就像魔法一样,形状从空白画布中浮现出来!关于魔法,本章开头提到的基本参考资料之一是着色器之书。雷达程序纹理是从其形状章节中列出的ShaderToy示例改编的,该章节位于thebookofshaders.com/07/。尽管网站上找不到节点图,但每个代码片段都是交互式的。任何对程序纹理或类似主题感兴趣的人都应该抽出时间和精力阅读这本简洁、温和且组织得惊人的资源,作为继续在这一领域旅程的一部分。

与程序纹理非常相似,NME 的后处理模式是与片段着色器相反工作的。让我们快速浏览一下 NME 中后处理的景观。

使用 NME 开发后处理

与程序纹理模式不同,Current Screen节点。这个节点是一个输入纹理,它被传递到片段着色器中。它包含了一个没有后处理的帧的截图预览。你可以为这个设置任何纹理;它的目的只是提供后处理输出的视觉反馈。

可以用 NME 构建的最简单的后处理之一是永不过时的淡入/淡出机制:

图 11.13 – MNE 中的简单淡入/淡出后处理

]

图 11.13 – MNE 中的简单淡入/淡出后处理

预览动画帧用于提供后处理在实际操作中的动画视图。fadeFactor统一变量控制效果的程度。代码片段托管在 Z4UNYG#2。

在前一张截图所示的逻辑中,fadeColor节点通过fadeFactor进行缩放。为了帮助可视化效果,Preview Animation帧传递了从 0(完全黑色)到 1(正常)再到 10(完全白色)的fadeFactors

重要提示

由于我们的比例从零开始,将 10 分配给fadeFactor与将事情提高到 11 一样,因为这就是 Babylon.js 所能达到的。你已经收到警告了!

我们本节开始时,学习了 NME 的基础知识,并使用我们新学的技术构建了一个高分辨率的地球材料,其中包括动画云覆盖。因为我们花了时间来讲解基础知识,所以我们能够在这个关于程序纹理的话题上取得进展。我们了解到,它们与材料构建得非常相似,除了所有工作都是在片段着色器中完成的。像程序纹理一样,后处理不针对场景几何形状操作。后处理在功能上与程序纹理相同,因为当前屏幕节点是屏幕上每个像素的渲染纹理缓冲区。

摘要

如果感觉我们一直在回避对这个以及其他我们在本章中已经涵盖的主题的深入探讨,那么要么是你有很好的直觉,要么是你阅读了本章的标题。正如本章标题所暗示的,我们只是在触及一个不仅广度大而且复杂性深的主题的表面。这并不意味着我们没有涵盖很多材料——恰恰相反!我们本章开始时,通过学习一些着色器概念以及顶点、片段和计算着色器之间的区别来入门。每种类型的着色器都是一种专门软件程序,它在 GPU 上为屏幕上的每一块几何形状(顶点)和每一个像素(片段)运行一次。

没有任何着色器实例会记住前一个帧中发生的事情,也不知道它们的邻居在做什么。这使得着色器程序在最初使用时有点令人费解。幸运的是,Babylon.js 中使用的着色器代码语言是GLSL,如果你习惯于使用 Python 或 JavaScript,你应该对此很熟悉。

计算着色器是 Babylon.js v5.0 的新功能,是WebGPU工具箱中的一个强大新补充。计算着色器是着色器的一种更通用的形式,与顶点或片段着色器不同,它们能够将输出写入不仅仅是纹理目标或网格位置。因此,它们可以执行复杂系统(如流体动力学、天气、气候模拟等)的并行计算,以及更多等待被发明的事物。

一旦我们对着色器有了坚实的基础理解,我们就将这一知识应用于使用 NME 编写着色器程序。NME 有几种操作模式,我们再次从基础开始,使用材质模式和平行地球材质。在快速学习如何添加和混合纹理后,我们通过添加动画效果到云纹理上,并在过程中学习帧,为这个蛋糕增添了糖霜。

当我们试图理解更加复杂的雷达程序纹理时,关于帧的知识派上了用场。忽略顶点输出,程序纹理和后处理模式作用于片段输出。这种相似性使得它很容易过渡到后处理编辑器。始终优雅且易于实现,基本的淡入/淡出效果很快就能融入我们对这个重要主题不断增长的理解中。

就像冰山露出水面的尖顶一样,着色器和 NME 中还有许多东西比表面可见的要多。如果这一章要公正地对待这个主题,无疑需要再增加几百页!请确保查看下一节扩展主题,以获取下一步的建议和行动指南。

在穿越 Babylon.js 广阔世界的旅程中,我们已经取得了惊人的进展,但仍有更多领域需要探索。在下一章中,我们将从快速通道开始转向出口车道,但在到达目的地终端之前,我们还需要标记几个地标。从现实世界的角度来看,我们将把焦点重新转向整体应用程序,学习如何让 Space-Truckers 在离线状态下运行并记录高分,然后再发布到主要的应用商店。如果这听起来像是一种令人耳目一新的环境变化,那么请继续阅读!否则,如果你在寻找一些支线任务来保持 Shader 领域的活力,扩展主题部分可能有一些有趣的挑战可以接受。下一章见!

扩展主题

这里有一些关于如何进一步使用 NME(特别是)以及一般着色器资源的想法:

第十二章:测量和优化性能

在软件工程领域,人们经常听到这样的说法:“过早优化是万恶之源。”这通常是由资深的开发者对资历较浅的开发者非常了解地传授的。无论是刮胡子还是不刮胡子,几乎总是需要做出严肃的声明,以获得这种声明所伴随的庄严气氛。不管传达方式如何奇怪,遵循这些建议是明智的。

在软件设计方面,没有比在软件仍在大量构建时开始进行性能相关更改更糟糕的方法了。这反过来是因为代码库的优化与代码的可读性、可维护性以及引入新功能和更改的便利性成反比。换句话说,代码库越优化,人们理解代码并对其进行更改就越困难。

在我们的旅程的这个阶段,我们已经建立了一个完整的端到端应用程序体验。尽管可能还有一些粗糙的边缘,但所有主要功能都已在该应用程序中实现,这使得检查应用程序的性能成为一个理想的时间点。然而,与此同时,我们对 Space-Truckers 在除最基本层面之外的其他层面上的性能了解不多。我们的第一个任务是明确的:我们必须捕捉一个基准性能配置文件,或者说为路线规划和驾驶阶段各捕捉一个配置文件。

Babylon.js 的 实时性能查看器 可以记录 Babylon.js 场景中与性能相关的广泛指标的实时性能统计。有了这些工具,我们将能够识别 Space-Truckers 代码库中的“热点”,然后针对这些热点进行选择性的性能优化,但这并不能告诉我们如何提高性能或在我们工具中寻找什么。至少目前还不是这样!

我们之前还没有讨论过,一个网络应用的广泛性和覆盖面也意味着必须由开发者支持更多不同类型的潜在硬件和软件配置。我们如何避免必须测试、验证和修复每个设备、软件和显示组合的功能?通过了解场景中哪些区域或场景对系统哪个部分施加了最大压力,我们可以将优化从设计时间推迟到运行时,并在实时进行处理。Babylon.js 的 场景优化器 是动态平衡性能和渲染质量的理想解决方案,它可以根据目标帧率和当前帧率之间的差异,开启和关闭不同的性能优化。

除了运行时的 场景优化器 之外,我们还可以做其他事情来提高 Babylon.js 应用程序的性能。我们将继续使用并重新测量我们做出的任何更改的影响,首先是单独的,然后是全部的,因为如果你没有比较的东西,你怎么知道是否有所改进?修辞的回答是,你不能——除非你在测量程序和捕获测量方面保持一致,但作为 Software That Does Magic™ 的挑剔和有条理的创造者,你已经掌握了这一点!

重要提示

严肃地说,有助于加强和促进这种开发工作的强大力量是利用 Git 的强大功能。每次你保存源代码文件的更改时,至少考虑暂存该更改,如果可能的话,提交它。对于不起作用的提交,选择回滚而不是继续前进。换句话说,通过与源代码控制合作,而不是对抗它,你可能会惊讶地发现你可以多么快地完成任务!

对于这一点,我们 Babylon.js 长途旅行的倒数第二个阶段,将查看我们应用程序的网络性能。具体来说,我们将看到我们的资产和数据资源如何影响加载时间和带宽使用。今天的网络浏览器几乎都支持强大的缓存功能,以及本地存储机制,如 IndexedDb,这是一个浏览器提供给其内部运行的脚本的微型 SQL 服务器。这有什么相关性?

注意

如果你错过了,修辞问题又流行起来了!

IndexedDB 的相关性在于我们可以用它来存放所有我们的资产——纹理、声音、JSON 等。我们不需要从服务器下载所有内容,而是在浏览器本地存储资源。这是一个缓存资产的绝佳位置。这使我们为下一章探讨将 Space-Truckers 转换为可安装发布的 渐进式网络应用PWA)做好了准备。但首先,让我们回顾一下本章涵盖的主题以及一些技术要求和推荐。

本章将涵盖以下主题:

  • 知道要测量什么

  • 测量性能和识别瓶颈

  • 使用场景优化器提高运行时性能

技术要求

本章中的大多数要求与之前章节的要求相同,但一些新工具对于性能测量和改进非常有用。以下是本章新引入的或特定的工具:

知道要测量什么

量子力学有一个叫做不确定性原理的概念。以物理学家维尔纳·海森堡的名字命名,这个原理可以概括为:测量某个数量本身会影响该数量的观测值。虽然对我们目前受限于非量子系统的人来说,这仅仅是一个类比,但它作为我们在采取测量和指标时的一个有用警告:不要让仪器影响我们对应用程序性能的测量。

从一些一般性指南开始,我们将查看一些需要关注和考虑的关键因素,以便收集有意义的测试数据。使用这些指南来建立基本背景,我们将开始学习一些关键术语,这将使我们能够在本章的后续部分中更具体地了解。

一般性指南

当我们回顾和检查获取性能配置文件的各种方法和程序时,我们会根据需要介绍工具特定的步骤。但首先,让我们看看一些普遍适用的指南。

将外部因素降至最低

计算机在在进程之间共享计算时间切片方面相当出色,但我们最好关闭所有其他浏览器窗口并关闭任何可能与我们竞争资源的非必要程序。不,这并不像预期的那样“真实世界”,但这里的目的是收集干净、一致的数据,这不必遵守“真实世界”的规则。反抗。

选择一个目标分辨率并坚持使用

这可能比看起来要复杂一些。仅仅选择最高可能的分辨率并使用最高像素密度的显示确实是一种很好的方法来压力测试图形应用程序,但这不会产生一个非常有用的性能配置文件。分辨率太低,GPU 也不会感到压力,同样也不会产生一个非常有用的配置文件。采用类似 Veruca Salt 和 Goldilocks 的方法,选择一个位于中上范围的值,避免“红线”CPU 或 GPU,但仍然让这些组件为它们的电子工作!

比较苹果和苹果

确保你的比较是等效的,考虑所有其他因素。遵循相同的测试程序——抵制“改进”或走捷径的诱惑——并在测试运行之间以相同的方式收集数据。如果方法不同,那么你的结果很可能不会告诉你你所认为的。

在测量之间只改变一个因素

您可能对自己做的最不有帮助的事情之一就是在进行一组更改后推迟重新测量。例如,假设您重构了一个方法,然后在应用程序的其他地方进行了一组更改。重复几次,现在您已经失去了确定您的更改是否改善了任何东西的能力——无论应用程序的性能是变好还是变差!这种情况也很糟糕,因为您在未来的更改中也受到限制,这些更改可能不会导致您试图更改的代码回归。通过将每个连贯的更改集一起提交,并在每次主要更改后重新测量以验证您对代码行为的假设,来避免陷入这种境地。

上述指南并非不可更改的规则——它们是旨在帮助您主动避免得出错误结论及其后果的建议。这当然很有帮助,但并非直接相关。为了帮助将这些建议与有用的背景联系起来,我们首先将探讨哪些类型的指标很重要。然后,我们将探讨收集这些数据的工具。最后,我们将应用所学知识来查找和修复 Space-Truckers 代码库中潜伏的性能瓶颈和资源压力。

性能相关术语

我想变得更快!

是的,Ricky Bobby,我们所有人都是这样。当提到汽车或赛车时,这个短语的含义是明确的,但对于一个 3D 应用程序来说,“[快速]”意味着什么呢?当然,对于一台笔记本电脑来说,突然以 200 公里每小时的速度在燃烧的塑料云中起飞绝不是什么好事!至少,不是没有轮子的笔记本电脑。刹车也同样重要。

对于 3D 应用程序和游戏来说,相应的度量标准当然是帧率,或每秒帧数FPS)。

注意

不幸地靠近第一人称射击游戏FPS),值得注意的是这两个并不是直接相关的,这再次提醒我们上下文很重要。

就像高速公路上发布的限速标志旨在限制(至少在理论上)道路上驾驶员的最高速度一样,每秒可以渲染的帧数最终受到一个内在最大值的限制,这个最大值与进行渲染的显示设备或监视器的刷新率相匹配。在旧时代,这受到阴极射线管CRT)显示器中电子枪穿越屏幕宽度和高度速度的限制。那是野蛮的时代。在当今更加先进的显示技术时代,发光二极管LED)显示器可以以惊人的速度开关。以下是一些典型的 FPS 值和例子,您可能从现实世界中认识:

FPS 是一个方便的指标,因为它几乎完全无歧义——更高的值几乎总是更好的。唯一的真正例外是在功率消耗比保持高帧率更重要的场景中。因为这与运行时采取的行动有关,所以我们将在本章的使用场景优化器提高运行时性能部分稍后探讨如何处理这种情况。更高的 FPS 的丑陋副作用是,用于获取所有所需帧间处理的时间更少,无论是在 GPU 内部还是在 CPU 上。

这个帧预算,在上一张表的第二左列中显示,决定了在帧间时间期间可能发生的事情。超过预算,帧率会下降。预算过低,就会浪费本可以渲染额外帧或运行其他处理任务的时间。可以通过减少 CPU 帧时间或 GPU 帧时间来管理性能。有时,两者之间会有交叉——一个很好的例子是用于路线规划阶段的小行星带的薄实例(有关更多信息,请参阅第七章粒子系统中的偏离部分,处理路线数据)。

每一帧,小行星的旋转和位置矩阵由在 CPU 上运行的代码更新,然后这些矩阵被复制到 GPU 中。然后这些矩阵被传递到顶点和片段着色器中,它们在单个 Draw 调用中将这些矩阵应用于场景中的每个实例。虽然这是一个非常快速的过程,但在 CPU 上存在一个潜在的瓶颈,那就是对每个薄实例的循环,它重新计算这两个矩阵。理论上,任何对此处的改进都会提高性能或在不严重降低性能的情况下渲染的小行星的最大数量。

将计算任务转移到 GPU 上时,当设备被分配了太多(或更少、更慢)的着色器程序,这些程序竞争相同的有限帧预算时,可能会出现瓶颈。着色器执行的原始数量以每秒进行的Draw调用次数表示,并作为执行着色器所花费的 GPU 帧内时间的补充。由于每个 Draw 调用都与一个单一的材料相关联(某些材料会对 Draw 进行多次调用),场景中不同材料的数量与 GPU 每秒被要求切换上下文以运行该材料的着色器程序的次数直接相关。

在硬件上,GPU 在上下文(着色器)之间的切换已经进行了残酷的优化,但这并不是完全免费的。每次切换都会带来一小部分开销,虽然单个切换微不足道,但大量切换累积起来可能会导致显著的损失。因此,减少绘制调用次数可以直接通过减少上下文切换来提高性能,间接地通过不再调用的着色器代码。

重要提示

最快的代码是不存在的代码。想想看。

还有一些其他值得定义的指标,但它们从名称或上下文中最为明显。然而,有一个例外,那就是绝对帧率(Absolute FPS)。绝对帧率是指每秒可以处理的帧数,不计实际渲染时间。这是衡量 CPU 方面通过其更新循环表现如何的一个指标。

与本书中的大多数内容一样,前面的术语并不是对 3D 性能编程领域的全面概述,但作为下一部分的入门,它已经足够全面。一个舒适的帧率——至少达到 60 FPS——大约有 16 毫秒的帧预算,在这段时间内,必须完成处理模拟和为下一帧做准备所需的所有处理。GPU 在执行这类任务时非常快,但就像过载的 CPU 可能会因为服务过多的竞争进程而旋转和翻滚一样,GPU 也可能因为着色器程序而过载。

为了帮助我们理解关于 CPU、GPU、负担以及在实际场景中发生的所有其他内容的讨论,我们需要了解如何以及测量什么。仅仅测量事物通常是不够的。就像化学学生在笔记本上规划他们的实验步骤一样,我们需要学习如何规划我们的测试策略,以及如何解读结果。在下一节中,我们将承担规划、执行和解读性能测试的任务,但在那之前,我们需要更多地了解那些将帮助我们完成这些任务的工具。

测量性能和识别瓶颈

有效的解决问题的开始是明确界定需要解决的问题。有时,这并不明显,有时,可能存在多个问题似乎是最重要的。通常,使界定问题困难的是它被呈现为一个定性陈述,就像这样:“路线规划屏幕性能不佳。”

这样的声明在一个意义上是不含糊的——对其含义没有疑问——但在另一个意义上是完全晦涩的,因为我们不了解性能有多差。这就是定性数据和具体、定性的度量之间的基本区别。没有前者,就无法理解整体情况,没有后者,就无法知道是否任何行动已经解决、缓解,甚至变得更糟。因此,收集有关路线规划屏幕性能的定量数据是我们需要采取的第一步,以便我们能够更好地定义我们的胜利条件。

检查路线规划性能

Babylon.js 检查器是一个多功能的有用工具。如果你对检查器还不熟悉,现在查看doc.babylonjs.com/toolsAndResources/tools/inspector文档,以及再次回顾第二章Babylon.js 入门,将是一个不错的时间,以帮助你入门。检查器长期以来一直有一个 性能 选项卡,显示有关当前运行场景的各种统计数据,但直到 Babylon.js v5.0 版本发布,还没有简单的方法来捕获和分析这些指标随时间进展的情况。性能分析器是一个可扩展的工具,有两个类似的概念但不同的实践模式:无头和实时。

重要注意事项

如果你不记得,在运行 Space-Truckers 时打开检查器的快捷键是 Shift + Alt + I

实时性能查看器指标

在实时模式下运行时,会渲染一个实时图表,显示从可用指标列表中选择的指标。相比之下,无头模式只显示内容,但会捕获可以稍后导出为 CSV 格式以进行进一步分析的数据。这三个选项(启动/停止、实时、无头和导出到 CSV)在 BJS 文档中有更详细的介绍,请参阅doc.babylonjs.com/toolsAndResources/tools/performanceProfiler。以下表格列出了性能分析器收集的默认指标,以及这些指标的基本说明:

前述每个指标的具体值将取决于硬件和软件环境,因此特定的目标值并不很有用。指标的不同属性分组通常反映了值所采用的维度或单位。顶部部分侧重于计数指标——例如网格、顶点、纹理等的数量。之后,是时间指标,显示了场景的特定部分在帧内和帧间消耗的时间量。性能分析器捕获并以此基础集的指标在视觉图表中显示。让我们继续看看我们的分析过程是什么样的。

定义测试程序

根据本章之前提出的指南,我们需要定义一个可重复的过程来分析应用程序。没有必要过度复杂化这一点,所以我们来做最简单的事情。我们希望刷新应用程序的网页以重置和清除内存等,然后我们希望让应用程序稍微稳定一下,找到它的节奏,在我们投放一些货物并获取更多测量数据之前。最后一步是将我们的性能配置文件保存到 CSV 文件中,以便将来加载到性能查看器中进行基本分析。

重要提示

除非有特定的原因不这样做,否则始终评估在生产环境模式下构建的代码的性能和捕获指标!

这是我们测试过程的样子。记住,我们希望在每次对代码进行重大更改时重复这一系列步骤,以便我们可以理解该更改的影响:

  1. 刷新浏览器,启动游戏,并导航到路线规划

  2. 允许游戏稳定 10 秒

  3. 开始捕获

  4. 允许 10 秒稳定并建立基线

  5. 将货物单元发射到空旷的空间

  6. 收集数据 10 秒

  7. 停止捕获并导出到 CSV

更彻底的测试过程还应该包括摄像头的平移和缩放,但这个程序将充分满足我们的需求。在本书的这个阶段,步骤 1不需要进一步阐述。步骤 2也很直接,切中要点。步骤 3是我们需要暂停以详细了解该步骤包含的内容。

在我们开始捕获配置文件之前,我们需要通过按Shift + Alt + I键组合来启动 BJS 检查器。右侧窗格中的统计信息选项卡包含我们的目标信息,但首先,请从浏览器窗口中分离检查器窗格(如果需要,可以关闭场景浏览器),以免占用或覆盖应用程序窗口的任何部分。如果您使用多个显示器,将一个显示器专用于浏览器窗口可能很方便,但这不是必需的。只需记住关于使用相同屏幕尺寸和分辨率的指南!准备好后,请按以下截图中的开始录制按钮:

![图 12.1 – 检查器的统计标签页包含用于启动、停止、导出和查看性能配置文件数据的控件图片

图 12.1 – 检查器的统计标签页包含用于启动、停止、导出和查看性能配置文件数据的控件

通过点击开始录制按钮,我们可以以无头模式启动性能配置文件。这使我们获得了更好的准确性,因为,向海森堡博士致敬,我们的测量不会对应用程序的执行产生太大的影响。

步骤 4 包含一个困难的任务,那就是在 10 秒内不触碰任何东西,等待应用稳定。这前 10 秒也有助于建立运行时的基准配置文件,我们可以用它来比较测试期间采取的不同操作。当规定的时间过去后,步骤 5 是将发射器指向一片空旷的空间并发射——我们想要捕捉游戏在飞行中的行为。让货物单元再巡航 10 秒后,步骤 6 已经完成,因此 步骤 7 接着到来,我们点击停止录制按钮,然后点击导出性能到 CSV按钮,以下载它。现在我们已经完成了配置文件的捕获,是时候检查它了。

查看和分析捕获的配置文件

查看性能配置文件最快的方式是使用CSV按钮选择加载性能查看器,然后选择之前捕获并新鲜下载的 CSV 文件以启动性能查看器。

重要提示

根据你是本地操作还是针对已部署的环境,你的浏览器的弹出窗口阻止程序可能会激活并阻止实时性能查看器窗口显示。确保你禁用或添加例外到你的阻止规则,以允许窗口出现!

你对性能图的第一个印象可能是有人洒了一盒彩色意大利面或可能是拾取棒,现在需要清理。这是因为所有指标都是在加载时选定的。点击组标题上的主切换按钮以禁用所有计数项,只留下 FPS。有选择地移除具有非常小值的项——如果某件事完成所需时间少于毫秒,有更好的事情要担心!现在图表更容易理解了!使用鼠标滚轮放大和缩小,同时通过拖动在时间轴上平移。

这将使我们从观察一个更大的整体画面转变为对事物进行越来越细粒度的观察,其中有一些值得注意的事项。

初始评估

注意帧间时间似乎与 FPS 成反比?也就是说,如果你仔细观察这两个数据系列,你会看到每当帧间时间在相反方向上有类似的位移时,FPS 都会急剧下降。在这个格式中,另一个显而易见的是,每当帧与帧之间的时间增加时,帧数就会相应减少。

如果我们将 GPU 帧时间添加到这张图中,一个更加细致的画面开始显现。尽管存在例外和异常值,但在大多数帧间时间增加(紧接着 FPS 下降)的区域,GPU 帧时间会有相应的减少

![图 12.2 – 性能配置文件的一部分快照

![img/Figure_12.02_B17266.jpg]

图 12.2 – 性能配置文件的一部分快照

在前面的图中,顶部较深的线条最初是 FPS,而最底部的线条是帧间时间。中间的是 GPU 帧时间。

如果 GPU 帧时间在改善,为什么 FPS 却在下降?如果没有关于 Space-Truckers 应用程序及其组成的了解,专家可能需要一些时间来弄清楚这种奇怪联系的原因,但本书 Space-Highways 的资深程序员读者可能已经确切知道这意味着什么以及是什么导致了这种情况。

整合外部知识

尽管 CPU 和 GPU 基本上是独立运行的,但影响一个的事件或条件仍然可以间接影响另一个。在我们的路线规划屏幕的情况下,我们可以推断 GPU 帧时间下降是因为它在等待 CPU 告诉它做什么。因此,帧间时间的增加是 FPS 下降和 GPU 帧时间减少的直接原因。

回想一下第六章实现游戏机制,并回忆我们在构建小行星带部分是如何实现小行星带的。带子由成百上千个单独的岩石网格组成,这些网格作为一组薄实例以程序方式生成。请注意,正如我们在第七章处理路线数据中讨论的那样,薄实例运行在 GPU 上,因此速度极快。

检查粒子帧步骤的时间通常支持这一说法,因为 CPU 在管理粒子上的时间足够短,不太可能是场景中使用的两个明显不同的系统(太阳粒子系统也是一个基于 GPU 的粒子系统,而小行星薄实例是另一个)的原因。那么,为什么我们要关注小行星带作为我们高帧间瓶颈的来源呢?这是因为我们的薄实例并不是静态保持在原地的——它们各自旋转。为了实现这种旋转,我们实施了一个方案,在该方案中,我们在 CPU 上本地存储了一组旋转、位置和缩放数据。每一帧,我们遍历小行星集合,调整每个小行星的旋转值,在向 GPU 发出刷新薄实例缓冲区的信号以更新屏幕上的对象之前,更新它们的矩阵:


Ior (lIt i = 0; i < this.numAsteroids; ++i) {
    this.rotations[i].x += Math.random() * 0.01;
    this.rotations[i].y += Math.random() * 0.02;
    this.rotations[i].z += Math.random() * 0.01;
}
this.updateMatrices();
this.mesh.thinInstanceBuf"erUpda"ed("matrix");

gameData 用于路线规划屏幕的数据包含一个 asteroidBeltOptions 配置对象,该对象反过来包含一个控制创建和管理小行星(薄实例)数量的数字属性。接下来,是时候通过运行实验来测试我们的假设了。

验证假设

将小行星的数量更改为当前值的约 75%,然后重新运行性能配置文件。应该立即明显地看出,帧间时间得到了改善,整体 FPS 也有所提高。正如我们所希望的,GPU 帧时间要么保持不变,要么呈上升趋势,支持我们关于 GPU 等待过载 CPU 工作的推测。

如果你希望做得更彻底(而且你应该这样做,如果你还在学习的话!),再次更改小行星的数量,但这次朝相反的方向,然后在测试之后重新进行。结果,再次支持我们提出的解释,即小行星的数量与 FPS 成反比,并且这种相关性在运行之间应该是一致的,展示了故事的定量和定性两个方面。

一定要将小行星的数量更改恢复原状,因为这是一个“一刀切”不适用的情况——不同的 CPU 能够支持不同数量的小行星而不会影响性能。我们需要能够在运行时根据应用程序的性能动态更改小行星的数量。再次证明,Babylon.js 拥有完成这项工作的完美工具——场景优化器。Babylon.js 检查器是进行性能分析和改进的起点。统计信息标签包含了一系列汇总的计数——纹理、网格等等——以及时间,如 GPU 时间和 FPS。补充这些,指标显示是新的实时性能查看器,它使用相同的指标绘制性能随时间演变的图表。它可以在实时和头身模式下运行,但头身模式对性能的影响最小。

通过点击按钮即可捕获和导出性能数据到 CSV 文件,但建立一个测试程序与收集到的数据一样重要(如果不是更重要的话!)!在定义我们的程序后,我们看到了如何执行它以捕获性能配置文件。在分析配置文件后,出现了一种趋势,表明 CPU 可能存在瓶颈,这是由于场景中涉及的行星薄实例数量造成的。由于捕获配置文件非常容易——改变行星数量并重新运行测试不需要很长时间,而且结果似乎证实了我们将行星数量与整体帧率联系起来的断言。

改善这种情况并不像仅仅减少行星数量那么简单。因为这与 CPU 处理各种矩阵计算的能力紧密相关,不同的 CPU 对相同的变量会有不同的反应。一个动态设置的行星数量,与 CPU 可以处理的数量相匹配,将是完美的解决方案。在下一节中,我们将了解如何使用场景优化器,无论是其原始的、开箱即用的配置,还是使用自定义策略。

使用场景优化器提高运行时性能

为特定平台开发游戏有其独特的挑战和好处。控制台游戏的好处是具有标准硬件规格和驱动程序可以针对,但代价是相同的硬件规格在其他领域,如 RAM 或视频 RAMvRAM),造成了严重的限制。基于浏览器的游戏也有自己的双刃剑——JavaScript 和网络的普遍性给硬件规格受限的控制台开发者带来了类似的问题,以及 PC 开发者必须面对的某些问题,这些问题是由于广泛的硬件组合造成的。

使用本章和本书前几章的工具和课程,很容易想象编写一些代码——可能是一个协程——来监控应用程序的实时性能,并根据需要调整各种设置以提高帧率。然而,想象起来可能很容易,也许原型设计或创建一个在少数有限情况下工作的概念验证原型也很容易。但魔鬼总是在细节中,而且需要投入相当的时间和精力,这些时间和精力本可以用于其他用途。

幸运的是,并且希望到这一点已经有些重复了,Babylon.js 已经通过SceneOptimizer为您提供了支持(doc.babylonjs.com/divingDeeper/scene/sceneOptimizer)。每当指定的采样间隔通过(默认情况下,每 2,000 毫秒),SceneOptimizer会检查当前帧率,如果它没有接近或达到目标,则应用队列中的下一个优化。如果优化能够采取进一步行动,它将保留在队列中,直到报告它不能再提供帮助。

通过SceneOptimizerOptions对象,SceneOptimizer从一系列策略队列中工作,每个策略提供不同类型的性能优化,允许在保持稳定帧率的同时优雅地降低场景质量。

内置优化策略可以执行的一些操作示例如下:

  • 将多个相似网格合并成一个网格

  • 禁用阴影和/或后期处理

  • 降低纹理分辨率或硬件缩放

  • 减少粒子数量

每个具体的优化都有一个优先级值,数值较低的优化首先应用。为了更方便,SceneOptimizerOptions提供了一套静态工厂方法,允许您根据愿意在场景中允许的视觉降级程度指定一组优化 - 低、中或高。有关具体优化用于何种降级级别的详细信息,请参阅之前提到的链接中的文档。有趣的是,SceneOptimizer可以被配置为反向运行 - 而不是降低场景质量,它将启用或应用效果,直到帧率下降到或低于目标。这在能量受限的场景中很有用,其中能量消耗是一个重要考虑因素,但不是我们将要讨论的领域(有关更多内容,请参阅扩展主题!)。

除了内置的优化策略之外,还可以定义自定义优化策略。这对于我们的主要目的非常有用,并且不需要超过一行或两行的 JavaScript 代码。我们将在为小行星带创建自定义优化策略部分稍后创建一个自定义策略,但首先,在我们学习走路之前,让我们先学习如何爬行,通过学习一些关于SceneOptimizer的知识。不要被高大的章节标题所迷惑 - 当我们查看其机制时,它相当简单!

理解场景优化器及其工作模式

Babylon.js 场景优化器以两种模式之一执行:改进模式和…!isInImprovementMode。这有点像内部玩笑,因为这是由true设置的最后一个参数确定的属性,优化会一直应用,直到达到目标帧率或我们用完了可应用的策略。当设置为false时,它执行相反的操作或增强视觉效果,同时帧率在目标帧率之上。每个优化(即使是自定义优化)都会根据设置的任何模式调整其行为,因此一个试图提高帧率的策略在优化模式下可能会关闭阴影,而在增强模式下会打开它们。

SceneOptimizerOptions模块使用的优化/增强策略列表。虽然可以从空白选项集开始,手动创建和添加策略,但SceneOptimizerOptions提供了一套静态工厂方法,可以根据动作的激进程度或广泛程度创建预定义的策略集。这三种方法从LowDegradationAllowedHighDegradationAllowed(有关每个策略中包含的具体策略的更多信息,请参阅doc.babylonjs.com/divingDeeper/scene/sceneOptimizer#options)。

重要提示

改变isInImprovementMode的值不会影响SceneOptimizer的行为——它只能设置的唯一地方是在构造函数中!

一旦设置了sceneOptimizer.start()并使用sceneOptimizer.stop()停止,为了帮助调试和故障排除(以及其他潜在用途),SceneOptimizer有一组三个可观察对象,分别在应用优化、成功或失败时触发。

任何要用于applygetDescriptionOptimizer(对于那些喜欢代码模式的人来说,这是一个策略)。apply(scene, optimizer)方法针对每个优化调用,并带有与getDescription当前priority匹配的priority,负责返回一个人类可读的文本描述,说明优化对给定场景做了什么。这就是基本水平上的全部内容——简单如承诺!从这个简单的基础上构建,现在我们已经准备好了,让我们关注之前提到的那个自定义优化。

为小行星带创建自定义优化策略

在本章的早期,我们使用了thinInstanceCount属性,这似乎是一个自定义优化策略的良好候选者。

尽管存在多种定义sceneOptimizerOptions.addCustomOptimization方法的方式。这个函数接受三个参数——applygetDescription的回调以及一个表示priority的值,这个值并非巧合地恰好是我们最近讨论过的Optimizer接口合同!

游乐场是我们在第六章,“实现游戏机制”中查看的一些早期 PGs 的修改和简化版本。这个 PG 只包含中心恒星和 TI 小行星带。调整asteroidBeltOptions.number的值,直到你得到一个较低的帧率,然后点击fastOptimizer来查看createScene方法体。大部分内容应该很容易理解,但一个可能的难题是这一行代码:


optimizerOptions.optimizations.forEach(o => o.priority += 1);

这里发生了什么?嗯,我们希望我们的 TI 优化器在尝试其他任何优化器之前先运行。是的,就像我们是一个独生子或第一个孩子一样——极其自私和以自我为中心——但这是我们自己的应用程序,我们知道我们在做什么。大部分时间是这样。但我们也不能允许其他优化以相同的优先级运行,因为我们不想在尝试其他任何改进措施之前改变除了 TI 数量之外的其他任何东西。因此,我们在optimizerOptions对象中的每个现有优先级上循环,将其优先级提升到比之前更高的值(默认值为零)。这样,在下一行,当我们以优先级0调用addCustomOptimization时,我们知道我们的东西是第一位的。去你的,年轻的兄弟姐妹们!自定义优化定义可以适应两种操作模式,在其完整版本中,它能够根据退化要求自动计算实例数量的最小值和最大值。以下代码为了简洁和清晰而进行了缩减,但除此之外,它与它的“大哥”在github.com/jelster/space-truckers/blob/ch12/src/thinInstanceCountOptimization.js上是一样的:


let optimizerOptions = new SceneOptimizerOptions(targetFps,
  2000);
optimizerOptions.addCustomOptimization((scene, opt) => {
    let currTI = mesh.thinInstanceCount;
    if (!opt.isInImprovementMode) {
        if (currTI <= MIN_INSTANCE_COUNT) {
            return true;
        }
        mesh.thinInstanceCount = Math.ceil(currTI * 0.91);
    }
    else {
        if (currTI >= MAX_INSTANCE_COUNT) {
            return true;
        }
        mesh.thinInstanceCount = Math.ceil(currTI * 1.09);
    }
    return false"
}, () => "Change thin ins"ance count");

前面代码中有趣的地方在于,我们不是通过固定的、固定的数量来改变 TI 的数量,而是以大约 9%的增量来改变它。这使得设计师和开发者可以更自由地对基本小行星数量进行更改,而无需对其他不同规模的价值进行其他更改。希望这很容易看出,如何轻松地对应用程序的视觉质量进行运行时调整,以满足目标帧率,因为这就是我们将要讨论的主题的范围,至少在本版书中是这样。

SceneOptimizer并没有什么神奇之处,尽管它为开发者节省时间的效果确实可能让人感觉如此。考虑到网络应用可以访问的无数个单独的性能特性,可能或实际的手动优化变得更为困难且昂贵。在设计时进行的性能优化与在运行时动态应用的优化之间的平衡可能是为尽可能广泛的受众获得美丽和流畅视觉效果的关键。

SceneOptimizerOptions对象定义了将要执行的一组优化以及是否应该运行它们以提高帧率以改善视觉效果。提供了许多内置优化,可以使用SceneOptimizerOptions.LowDegradationAllowed及其配套方法快速创建,但自定义优化几乎同样快速且易于使用。我们的自定义优化器会改变 TIs 的数量,直到达到目标帧率。通过传递一个apply函数、一个getDescription函数和一个优先级数字到optimizerOptions.addCustomOptimization,将自定义优化器添加到优化集合中,目的是让它独立运行。因此,在我们这样做之前,我们必须调整现有的优化优先级,以确保我们的优化既排在第一位,又独立于队列中。

摘要

当将内容浓缩成提纲形式时,可能看起来我们在这章中并没有涵盖很多内容,但事实远非如此!当然,关于性能优化和测量的许多重要领域几乎没有提及。我们没有涉及使用八叉树来加速碰撞和网格选择(doc.babylonjs.com/divingDeeper/scene/optimizeOctrees),切换各种便利缓存以减少内存占用(doc.babylonjs.com/divingDeeper/scene/reducingMemoryUsage),或者任何其他几乎二十种具体的优化启发式方法(doc.babylonjs.com/divingDeeper/scene/optimize_your_scene),这些方法构成了改进的“低垂的果实”领域。尽管如此,这也是可以的。这本书的标题是以“走得更远”开头,而不是“深入挖掘”,我们总可以在第二版(如果有的话!)中深入这些细节。

我们所涵盖的是如何思考和学习性能测试和剖析的基础知识,从一般指南和建议开始,然后过渡到 Babylon.js v5 新引入的实时性能查看器工具。利用这些技能,我们捕获了我们的应用程序,并使用它来识别表明性能对变化敏感的因素,例如在路线规划小行星带中渲染的小行星数量。最后,我们看到了如何通过场景优化器轻松进行基本场景优化。我们通过一个自定义优化策略解决了之前识别的性能瓶颈,该策略将逐渐减少薄实例的数量,直到帧率达到可接受的水平。

在下一章中,我们将学习如何将我们的游戏从普通 Web 应用提升到渐进式 Web 应用。这将是我们使游戏完全可玩并随时可供所有人访问的最终步骤;到下一章结束时,我们将有一个可以离线运行并发布到主要应用商店的应用程序!

扩展主题

正如往常一样,在本章中我们探讨的主题还有更多值得学习和探索的内容。以下是一些你可以进一步参与和实践本章所学知识的方法。别忘了在 Babylon.js 论坛或forum.babylonjs.comgithub.com/jelster/space-truckers/discussions的 Space-Truckers 讨论板上发布你的问题和分享你的成就:

  • 对小行星带数据进行更全面的定量分析,以提取帧数和 asteroid 数量之间的精确关系。具体的帧数与 asteroid 比率是多少?拥有 CSV 文件在这里很有用,因为像 Excel、Sheets 和 Google Sheets 这样的电子表格工具是对比和计算这些数字的最佳方式。

  • 有没有方法可以重写AsteroidBelt.update方法来减少 CPU 帧间时间?也许如果可以将小行星以包或批次处理,就不必逐个循环遍历每个小行星了…

  • 沿着前面提到的要点,我们是否可以重构小行星带,使其行为与当前完全相同,但全部在 GPU 上完成?鉴于我们在上一章中学到的关于着色器和节点材料的知识,答案应该是热情的“YES!”。现在就去证明这一点吧!

  • 将自定义小行星场景优化策略反转,以添加薄实例而不是移除它们。将此集成到应用程序中,以便场景尝试保持 24 到 60 帧每秒之间的舒适 FPS 范围。

  • 为用户提供配置整体图形质量偏好设置的选项。他们的选择可能会影响包含以改善视觉效果或性能的特定SceneOptimizerOptions

第十三章:将应用转换为 PWA

在过去的几章中,可能已经逐渐变得明显,我们正在接近旅程的终点。穿越 Babylon.js 的广阔乡村,我们看到了很多,也做了很多,Space-Truckers 由于我们的努力已经是一个功能完整的游戏。现在,在我们进入第十四章的“扩展主题,扩展”章节的旁路之前,我们还需要在本章中完成高速公路上的最后一程。

虽然我们接近了终点,但这并不意味着我们已经到达那里——在 Space-Truckers 或轨道力学方面,接近并不算数,只有马蹄铁和手榴弹才算数。Space-Trucker,我们前面还有一段路要走,在我们达到服务时间限制之前还有时间,所以让我们继续前进吧!

在本章中,我们将揭示 Babylon.js 与 渐进式网络应用PWAs)之间的交集。PWA 是一个中间空间,是基于浏览器的传统网络应用(无论这些天意味着什么)和原生桌面应用之间的混合体。它们可以像网站一样浏览,像应用一样安装,并且在没有互联网连接的情况下离线运行。用户还可以从他们的设备或主要的 App 商店中找到并安装 PWA——无论是 Google Play、Microsoft 还是 Apple。这为开发者提供了更大的空间,允许潜在用户发现他们的应用,同时实现成本和努力都相对较低。

重要提示

Apple 对 PWAs 的支持远远落后于其他提供商。对在 iOS 上运行的 PWA 施加了一些严重的限制和限制,并且在支持不同功能时可能会有自己的怪癖。请参阅优秀的第三方网站 firt.dev/notes/pwa-ios/,了解 Safari 和 iOS 的最新功能支持。

将我们的现有网络应用转换为 PWA 既简单又容易,但确实需要对应用进行一些修改。需要一个 Service Worker 来支持离线功能,需要一个清单来描述我们的 PWA 的特性。这包括图标、描述,甚至可能还有用于商店提交的截图。然而,Space-Truckers 缺少的一个最终特性我们将在本章中实现(如果我们没有这样的东西,这还算得上是“走得更远”的章节吗?)。

这个功能在历史上导致了无数争论和兄弟姐妹、好朋友之间的紧张关系。这个功能使得人们可以吹嘘自己的高分,回想起那些经典的荣耀日子,如 GalagaPac-ManDonkey Kong。当然,这个功能是一个高分榜。我们追求的是一个比那些日子稍微现代一点的计分板,因为我们的计分板将保存不仅仅是前 10 名的高分;它还将通过将数据保存到 IndexedDB 来保留这些分数在应用程序启动和计算机重启之间的状态。

这就是本章计划的全部内容——与之前的章节不同,这里需要的概念性和理论性讨论要少得多,所以让我们开始吧。不过,首先让我们快速浏览一下本章的 技术要求部分。与之前的不同,这是因为,正如我们很快就会了解到的那样,PWA 有一些特定的主机要求必须满足。

我们将在本章中介绍以下主题:

  • PWA 简介

  • 将 Space-Truckers 应用程序转换为 PWA

  • 使用 IndexDB 持久化分数

  • 存储和显示高分

技术要求

本章有新的技术要求,尽管成本很低甚至没有成本,但确实需要您做出一些决定,并可能需要进行一些研究,以帮助您达到最适合您项目的具体结果。在讨论 安全套接字层SSL)证书要求之后,我们将介绍一些托管您的 PWA 的更受欢迎的选项。

主机要求

简单来说,SSL 是一种机制,通过它客户端可以验证特定服务器的身份并建立加密的通信通道。它实际上是 HTTPS 中的“s”!SSL 连接是 PWA 的一个必要条件,没有例外。尽管没有例外,实际上有一个例外,那就是 localhost 回环地址,这使得测试更加容易。在大多数情况下,获取有效的 SSL 证书是免费且易于执行的。根据您的托管设置,SSL 支持甚至可能已经集成到托管平台中!请查阅您特定提供商的文档,了解更多有关如何获取和绑定站点到证书的信息。

托管您的 PWA 的选项

在将应用程序制作成 PWA 方面,主机提供商并没有什么特别之处;任何支持 SSL 的公开网站都有能力托管 PWA。某些环境可能会使围绕 SSL 或 HTTPS 基于的 Web 应用程序的组织和流程变得更容易或更难,因此这里有一个表格列出了一些主要的托管选项:

图片

如果你的应用程序源代码已经在 GitHub 上托管,GitHub Pages 是一个最简单的选择。部署到 GH 页面站点涉及将提交推送到一个特别命名的(且从未合并的)分支。在我们的情况下,提交的内容实际上是 npm run build 的输出——dist/ 文件夹及其所有内容。

.github/workflows 文件夹包含详细信息。

Google 和 AWS 静态站点都是独特的产品,尽管如此,它们仍然提供了与之前两个相同的基本服务。AWS 通过 AWS Amplify 提供静态网站,而 Google 也在其云存储产品中提供了类似的服务。这些服务旁边带有星号的原因是,基础产品不支持自定义域名,也不支持在这些自定义域名上提供 HTTPS,至少不是默认情况下。开发者需要做更多工作来添加其他必要的基础设施组件(例如 HTTPS 代理),以实现和满足自定义域名和 SSL 或 HTTPS 的要求。有关如何进行此操作的更多信息,请参阅下一节提供的文档——我们面前没有足够的道路可以避开这个话题的细节!

资源和阅读

PWAs 简介

如介绍中先前所述,PWA 是一种介于网络应用程序和常规桌面应用程序之间的混合类型的应用程序。在没有额外上下文的情况下,这是一个几乎无意义的描述。不是个别单词,也不是“Web App”这个术语缺乏清晰性,那么对于一个网络应用程序来说,“渐进式”意味着什么呢?

好吧,正如我们大多数人当然所意识到的,网络浏览器的安全模型与常规应用程序或游戏大不相同。在浏览器沙盒环境中运行的 JavaScript,按照设计,对底层机器的硬件和文件系统有极其有限的访问权限。对我们讨论来说重要的是对脚本的限制,以及浏览器中广泛的支持实现,这意味着任何给定的网络应用程序可能或可能没有访问某些设备功能和功能。在这些类型的案例中,或者当应用程序被广泛分发到不同的设备和软件配置文件中时,对于应用程序能够根据托管设备拥有的和愿意与浏览器应用程序共享的内容,按需“渐进式”和优雅地增强或降低其功能,这一点非常重要。

这就引出了下一个问题:PWA 是如何工作的?一个网络应用程序必须满足三个主要定义要求,才有资格被网络浏览器安装为 PWA。这些要求是 SSLService Workers(Web) ManifestsSSM)。这么多需要消化的词汇,时间却如此有限。让我们准备一把沙拉叉,更详细地探讨这些内容。

注意

如果你喜欢(或者如果你只是真的很喜欢让人困惑),你可以使用 MMS 或甚至 SMS 作为首字母缩略词。做你自己吧!

SSL

通过 HTTPS 进行,这是一个不可协商的要求 – 而且原因很好!将网络应用程序作为 PWA 安装大大扩展了应用程序的功能,但同时也相应地使主机机器及其数据面临更大的风险,恶意或不称职的参与者可能会访问它。要求客户端和服务器之间建立安全连接既不能弥补不称职或糟糕的编码,也不能保证涉及的服务器免受恶意意图的侵害。它所保证的是,托管站点的身份已经得到验证,确认为该站点所声称的那样。

Service Workers

以前用于从离线使用中获取和检索资产,SW 是从与主应用程序代码分离的 JavaScript 文件中加载的代码。在无 DOM 的沙盒中运行,SW 仍然是应用程序和底层网络之间的关键中介。SW 允许应用程序以透明的方式离线使用 – 应用程序中没有任何部分知道当它为资源发出网络请求时,它实际上是在与 SW 通信。

每当脚本、HTML 标签或 CSS 定义触发来自 Web 应用程序的网络请求时,该请求会被 Service Worker 拦截和处理。然后,Service Worker 可以选择从其缓存中返回指示的资源给调用者,或者刷新其缓存后再返回资源。为了使这一过程更加有效,Service Worker 在安装和激活后的首要任务是预先获取所有资源并将它们提前放入其缓存中。

Web 清单

最后,要“解锁”浏览器中 PWA 的功能,需要的是一个 Web 清单。这是一个简单的 JSON 格式文件,通常以 .webmanifest 扩展名,它向 Web 浏览器和其他清单消费者提供了关于应用程序的各种有用信息。除了包含关于应用程序的基本信息,如名称、描述和版本外,清单还包含允许开发者指定图标图像的章节,这些图像可以以不同的尺寸和比例显示在宿主操作系统(例如 iOS 主屏幕)上,显示方向偏好、截图,甚至年龄和内容评级。在 Mozilla 开发者文档网站上查看可能的元素及其含义的完整列表,请访问 developer.mozilla.org/en-US/docs/Web/Manifest

这些不同的元数据共同工作,描述了应用程序应该如何呈现以及其预期行为的参数。此外,当在应用商店中列出 PWA 时,Web 清单被大量利用。能够一次性定义应用程序的元数据并在任何地方发布的好处应该是显而易见的,但如何轻松地为这些属性定义值则不是那么简单。幸运的是,就像使用 Babylon.js 一样,有许多工具和资源可以帮助加快这个过程。

我们将在稍后介绍 PWA 的工具和机制,但首先让我们总结一下我们对 PWA 以及它们如何工作的了解。当用户浏览到一个启用了 PWA 的网站时,浏览器中会出现一个图标,表示可以为当前网站安装一个应用程序。点击该图标将原本的普通网站转换为一个离线可用、可固定到开始菜单的应用程序,与原生应用程序无法区分。

使得这一切成为可能的是 PWA 必须正确配置的三个具体事物——SSM 三重奏:(S)SL 连接,一个(S)ervice Worker 来预先获取和缓存离线使用的资源,以及一个 Web(M)anifest。拥有 SSL 连接意味着托管 PWA 的网站是通过 HTTPS 协议访问的,并且需要获得有效的证书来执行此目的。SWs 是运行在浏览器应用程序代码的独立沙盒中的 JavaScript 代码组件。它们透明地拦截请求并返回存储在本地的缓存资源。Web Manifest 描述了托管操作系统和浏览器需要知道的一切,以便安装 PWA。此外,Web Manifest 还充当应用商店包列表,这意味着通过准备单个商店提交的努力,可以提交到所有主要的应用商店。

在下一节中,我们将增强 Space-Truckers:Web 应用,使其成为 Space-Truckers:渐进式 Web 应用。我们将看到两个简单的包,加上一点代码和 WebPack 配置,就足以完成这项工作。从某种意义上说,转换的简单性和容易性可能有点令人失望,但别担心——我们很快就会看到,当我们来到高分榜时,我们会添加一些更炫酷、更有特色的东西!

将 Space-Truckers 应用转换为 PWA

正如我们在本节之前简要讨论的那样,PWA 的一个标志性的——或称特征性——功能是其能够优雅地适应不同的条件和托管环境。当网络连接丢失时,应用程序应该如何表现?当发布应用程序的新版本时会发生什么?当资源发生变化时,你如何确保删除任何旧的缓存资源版本,并存储新的版本?

将“P”融入 PWA

这些都是很好的问题,它们提出了需要解决的真实的技术和工程挑战。如果你是那些美丽、好奇、聪明且有点疯狂的人之一,你应该准备好自己可能会失望。虽然,再次强调,这些都是值得研究和理解的有价值的话题,但这是一个工具已经发展到可以知道很少关于底层技术的情况下完成很多事情的情况。这个部分——或者更确切地说,整个章节——的总体简洁性可能没有逃过你的注意,这只是一个提示,说明了将“P”融入 PWA 是多么容易。

也可能这本书的作者最初计算了承诺的页数,并意识到预算的页数已经很久以前就超出了,但这里没有人来争论哪个是哪个,对吧?

注意

[是的,实际上,我们非常关注页面数量。 —— 编辑们]

那么,为了尽快进入正题,并且避免进一步激怒编辑们,让我们一步步来,将 Space-Truckers 转换成一个 PWA。

第一步 – 安装 Workbox WebPack 插件

Workbox 是由 Google 维护的一个开源项目,旨在使 SW 的创建、使用和管理变得顺畅和简单。该项目还维护了一个与 WebPack 集成的插件(见 第四章创建应用程序),并自动为你生成 SW 代码。使用以下命令将它们作为开发依赖项安装到项目中,与 CopyPlugin 一起:

npm i workbox-webpack-plugin copy-webpack-plugin --save-dev

copy-webpack-plugin 是一个简单的插件,它将静态文件从指定的目录复制到与 webpack 输出相同的目录中,这在我们需要将图标和 Web Manifest 包含在构建中时非常有用。

第二步 – 配置 WebPack 插件

我们已经将新插件提供给 WebPack,现在我们需要将它们导入到 webpack.common.js


const WorkboxPlugin = require('workbox-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');

接下来,我们将使用各自的选项实例化插件。如果你还记得 第三章建立开发工作流程,WebPack 插件的运行顺序是它们被定义的顺序。这些新插件需要在将包注入 HTML 模板和清理目标目录之后运行:


plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
        template: path.resolve(appDirectory, "public/index.xhtml"),
        inject: true
    }),
    new WorkboxPlugin.GenerateSW({
        clientsClaim: true,
        skipWaiting: true,
        maximumFileSizeToCacheInBytes: 8388608,
    }),
    new CopyPlugin({
        patterns: [
            { from: path.resolve(appDirectory,
              'public/assets/icons'), to:
              path.resolve(appDirectory,
              'dist/assets/icons') },
            { from: path.resolve(appDirectory,
              'public/manifest.json'), to:
              path.resolve(appDirectory,
              'dist/manifest.webmanifest') }
        ]
    })
]

GenerateSWInjectManifest,它们的使用案例分别属于“基础”和“高级”类别。我们的需求目前非常基础,所以我们使用 GenerateSW 插件。它的配置有标志指定 SW 应立即声明匹配的客户端(对于升级场景)以及跳过等待旧工作者被销毁。最重要的是,我们将 maximumFileSizeToCacheInBytes 设置为其默认值的四倍。这是因为我们希望尽可能多的资产被本地缓存。

在后续步骤中创建这些文件之前,我们将对 index.xhtml 文件进行一些修改。

第三步 – 修改 index.xhtml

需要对存储库 /public 文件夹中的 index.xhtml 文件进行两项重要修改。第一项是在文件的 <head> 标签中添加一个 <link> 标签用于 Web Manifest。第二项是在页面加载时加载和注册 SW 的简短 <script> 标签:


<link rel="manifest" href="./manifest.webmanifest" />
<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('service-worker.js')
      .then(registration => {
        console.log('SW registered: ', registration);
      }).catch(registrationError => {
        console.log('SW registration failed: ',
          registrationError);
      });
    });
  }
</script>

按照 PWA 倡导的优雅增强策略,我们的脚本对应用程序的其他部分是完全透明的——当它存在时,一切正常工作。在检查浏览器是否支持 SWs 后,使用 SW 脚本的名字和路径调用 navigator.serviceWorker.register 函数。这个脚本由 GenerateSW 生成并输出到 /dist 文件夹,所以文件引用似乎不存在不应该让你感到困扰!

在这个阶段,运行应用应该会生成预期的控制台消息,表明成功注册和操作了 SW。可能出现的常见问题包括 SW 的路径或文件名不正确,或者 GenerateSW 配置不正确。这样,PWA 要求中的 SW 部分就满足了——让我们来填写缺失的 Web Manifest。

第 4 步 – 添加 Web Manifest

如前文在 PWA 简介 部分所述,Web Manifest 是一个开发者友好的、JSON 格式的文件,描述了 PWA 的属性和特征。为了保持开发时的体验,我们将 Web Manifest 放入 /public 文件夹中,作为 index.xhtml 的同级文件。这确保了当我们的链接在 webpack-dev-server 上托管或为生产环境构建并从 /dist 文件夹托管时,链接能够正常工作。

文件名为 manifest.json,位于 /public 文件夹中,然后在构建时重命名为 manifest.webmanifest。以下是一些更重要属性:

图片

对于 icons 数组,数组中的每个条目指定了一个图标,包括空白分隔的大小列表,以及一个路径,该路径直接指向一个文件或带有大小前缀的基本文件名——例如,myicon,其中文件名为 48x48-myicon52x52-myicon。为每个可能的图标大小提供条目不是必需的,尽管根据源图像,可能会出现一些扭曲和意外的显示效果。在此阶段,当应用程序在本地 Web 服务器上运行时,浏览器应该能够“点亮”,允许将网站作为 PWA 安装。如果不行,请打开浏览器开发者工具并检查相关的控制台错误。在 Google Chrome 和 Microsoft Edge 浏览器的 Lighthouse 选项卡中可以扫描网站的各种问题以及优化问题,包括涉及 PWA 的问题。

Web Manifest 架构定义了许多其他属性,尽管不是很多是必需的,但许多是推荐的。要查看 manifest 中可用的更多属性,请参阅 developer.mozilla.org/en-US/docs/Web/Manifest。手动创建和管理所有不同的元数据可能很困难,更不用说创建图标了,这就是为什么有工具可以帮助我们快速完成任务。之前讨论过的一个这样的工具是 Workbox 项目。另一个我们尚未讨论的工具是 VSCodePWABuilder 扩展

使用 PWABuilder 扩展

手动执行前面的步骤有一些好处。你在学习过程中可以精细控制每个细节,同时了解所有事物的内部结构。这同样是一个繁琐且容易出错的过程。前面步骤的一个替代方案是使用官方的Visual Studio Code PWABuilder 扩展。这个扩展由维护着优秀资源PWABuilder.com的同一团队开发和维护,使得设置 PWA 变得快速且简单。除了为各种 PWA 组件生成源代码外,该扩展还可以验证现有网站以检查其 PWA 准备状态——这对于调试非常有用。

VSCode扩展市场安装扩展后,打开扩展的左侧面板,以显示Web ManifestService Worker面板。在相应的面板上点击+图标以生成这些资源。在生成应用程序图标时,根据你的设置,PWA 扩展可能会生成整个范围的图标大小——数量可能超过 60 个。因此,一旦图标生成,你可以随意将这些文件缩减到最适合的一组大小。务必更新清单以删除这些文件!

在生成 SW 时,扩展会询问你是否想要npm包(例如workbox),并提供一个代码片段供你复制粘贴到index.xhtml中。代码看起来熟悉吗?

制作 PWA 的最后一步当然是发布应用程序到 HTTPS 主机。具体细节取决于你的主机提供商,但 Google、AWS 和 Microsoft 都提供了可以简化发布过程的 VSCode 扩展。无论涉及哪个提供商,目标都是运行build脚本,然后将/dist文件夹中的所有文件复制到主机网站的根目录。

正如本节引言中所承诺的,可用的工具和技术使得创建和部署 PWA 变得极其简单和快速。需要一系列四个简单的步骤——添加两个 Webpack 插件包以生成 Service Worker,修改webpack config,然后修改index.xhtml以注册 SW 和链接资源,最后添加 Web Manifest 来描述所需更改的范围。无论这些更改是手动执行还是借助如 PWA Builder Extension 之类的扩展,都会为应用开发者打开一个丰富的原生应用功能世界。我们还没有看到 PWAs 的全部功能和范围,所以请访问PWABuilder.com了解更多关于它们可以执行的不同酷炫技巧!

在我们结束本章内容之前,还有一个话题需要探讨。高分是像IndexedDB这样的街机风格游戏的基础,大多数现代浏览器内置的IndexedDB对象存储是解决这类问题的绝佳方案,在接下来的章节中,我们将学习如何创建一个组件来利用它。

使用 IndexedDB 持久化分数

需要在本地客户端存储信息的 Web 开发者传统上只有有限的选择,其中大多数都有显著的缺点。最古老和最简单的方法之一是谦逊的浏览器 cookie。这些存储在客户端浏览器上的小文本文件会随着浏览器客户端发出的每个请求一起发送到服务器。正因为如此,以及类似的原因,cookie 并不是解决许多如果不是大多数客户端存储需求的效率高或实用的解决方案,包括我们自己的。要深入了解不同客户端存储的优缺点,请参阅web.dev/storage-for-the-web/

IndexedDb 对象存储IDB)是一个客户端、浏览器沙盒数据库,在主要浏览器和平台上享有广泛且一致实现的跨平台支持。尽管一个网站允许存储的数据量限制在浏览器可用的磁盘空间内,但我们的应用程序除了资产之外,对存储空间的要求非常有限。

重要提示

虽然可以使用 IDB 作为纹理、网格等资产的缓存,但使用本章早期设置的 SW(Simple Web)设置对这些资产来说要容易得多、效率更高,并且整体上更适合。

本节的重点是 IDB 的基础知识以及我们如何在我们的应用程序中使用它。在花点时间回顾IndexedDb的基础元素之后,我们将编写一些代码,用更适用于应用层的辅助函数包装底层的 IDB 函数。然后,我们将看到如何将这些辅助函数集成到我们在下一节中使用的 Playground 中,即存储和显示高分

了解 IndexedDB

IndexedDB是由万维网联盟w3c)维护的官方 API 规范,该组织还负责大多数基于 Web 的标准,如 HTML 和 CSS。规范可以在w3c.github.io/IndexedDB/找到,但在这里我们不需要深入探讨以了解 IDB 的工作原理。

对于 IDB 规范在 Web 浏览器中的广泛支持让我们有信心可以继续使用这些 API,并且相同的代码应该在不同的浏览器中工作得一样好——关键短语是“应该工作”。不要忽略在不同浏览器和版本之间的测试。否则,你可能会面临很高的风险,遇到使用与您不同的设置的最终用户的支持问题!

当涉及到使用 IDB API 时,有两点需要注意。首先,它们是异步的。其次,操作通过各种事件处理函数产生结果。当一个异步操作被调用时,该函数的返回值不会立即可用——该函数不返回任何内容。此外,操作可能成功也可能失败。在前一种情况下,结果由特定于操作和对象的特定事件处理函数产生。IDBOpenRequest对象具有onsuccessonerroronupgradedneeded等事件处理程序,而IDBObjectStore对象具有transaction.oncomplete等事件。正如一些名称所暗示的,操作失败的情况由onerror处理函数处理。

一个重要的考虑因素是如何管理indexedDB.open函数的各种代码路径。onsuccess事件产生一个IDBDatabase实例,但这只是故事的一部分。当请求一个不匹配任何现有对象存储的数据库名称和当前模式版本的唯一组合(Open操作的第一个和第二个参数)时,将触发onpugradeneeded事件。正是在那时,特定的对象存储创建了其模式,添加了任何索引,并执行了任何版本更改迁移。

对于我们开始编写代码来说,这些基础概念已经足够了!我们需要编写一些辅助代码,这些代码将基于事件异步的IndexedDB函数,并使它们在我们的应用程序中易于使用。

使用 IndexedDB

IndexedDB相关的所有不同需求、场景和数据模式定义构成了几个动态部分。因此,我们的第一个任务将是围绕这些操作构建一个包装器,以函数返回 Promise 的形式公开所需的 API。有几个库实现了类似的辅助代码,但为了我们的简单需求,自己编写代码更具说明性和实用性。

注意

以下代码模式在 JavaScript 编程中很常见,作为一种将较低级别或遗留编程接口包装成更易于高级应用程序消费的形式的方法。如果您不熟悉这种模式,它是一个值得拥有的工具箱中的有用工具!

代码片段#U20E4X包含了我们将用于本部分和下一部分的内容,所以请跟随我们的步伐,一起探索一些更有趣、更不透明和更复杂的样本部分。

我们将首先声明并存储SpaceTruckersDb函数作为我们的外部作用域。该函数的主体包含我们工作集的变量,这些变量在辅助函数之间共享以保持内部状态,以及一个包含种子scoreDataconst数组:


let SpaceTruckersDb = function () {
    const scoreData = [
        { name: "AAA", score: 10000 },
        { name: "BBB", score: 7000 },
        { name: "CCC", score: 5000 },
        { name: "DDD", score: 3400 },
        { name: "EEE", score: 3000 },
        { name: "FFF", score: 2500 },
        { name: "GGG", score: 2000 },
        { name: "HHH", score: 1000 },
        { name: "III", score: 1000 },
        { name: "JBE", score: 500 },
    ];
    let indexedDbIsSupported = window.indexedDB;
    const currentSchemaVersion = 1;
    const databaseName = "SpaceTruckersDb";
    const tableName = "HighScores";
    var database;
// ... 
return { retrieveScores, addScore, readyPromise };

跳到函数的底部,我们返回一个包含辅助函数的对象,这些函数用于检索分数列表以及添加新的分数。与这些函数并列的是readyPromise,它用于在调用者选择的时间检查并确保完整初始化。由于我们的需求非常简单,目前不需要任何额外的逻辑或方法。

整个示例中最复杂的逻辑是第一步——初始化 IDB 对象数据库以及相应的对象存储(或表),我们使用它来存储应用程序的评分数据。这很棘手处理,因为代码可能需要根据对象存储是否已存在以及对象存储的架构版本是否与请求的最新版本匹配来采取多个潜在分支。

这就是onupgradeneeded事件处理器必须处理的内容。我们从readyPromise代理函数体的顶部开始,通过调用indexedDb.open来执行操作。这返回(这是 IDB API 中唯一发生这种情况的时候之一)一个带有其伴随的onerroronsuccess以及当然的onupgradeneeded事件的openDbRequest对象。错误逻辑很简单——拒绝readyPromise并传递抛出的错误。成功逻辑也很简单——只需将数据库变量设置为event.target.result,并用它来解决承诺。

注意

记住,对于给定的currentSchemaVersiondatabaseName,脚本第一次运行时不会触发onsuccess事件。相反,会触发onupgradeneeded

让我们看看onupgradeneeded事件会发生什么。在从事件对象中提取数据库之后,我们创建objectStore本身。autoIncrement标志表示新记录应分配一个自动递增的键,随后是创建非唯一分数索引。这很重要,也是必需的,以确保分数按正确的排名顺序存储:


openDbRequest.onupgradeneeded = (event) => {
    database = event.currentTarget.result;
    database.onerror = handleError;
    let objectStore = database.createObjectStore(tableName,
      {
            autoIncrement: true
      });
    objectStore.createIndex("score", "score", 
      {unique: false});
    objectStore.transaction.oncomplete = (event) => {
        let scoreStore = database
       .transaction(tableName, "readwrite")
            .objectStore(tableName);
        scoreData.forEach(scoreD =>
          scoreStore.add(scoreD));
        resolve(database);
    };
};

在创建分数索引后继续操作,我们将一个函数附加到objectStore.transactiononcomplete事件。这个函数立即对同一表(scoreStore)发起一个readwrite事务,然后用于用一组初始高分(scoreData)填充最初为空的分数表。在将种子数据添加到存储后,我们解决readyPromise——不需要等待写事务完成。这是我们为此组件拥有的最复杂的逻辑。

retrieveScoresaddScore 函数都是基于 onupgradeneeded 事件逻辑所展示的主要主题的简化变体。一个带有请求的只读或读写权限的 txn 对象被创建。然后从事务中检索 objectStore 并用于执行 getAlladd 操作。对于 getAll,结果在 objectStore.getAll 返回的对象的 onsuccess 处理程序中产生,类似于 indexedDB.open 的结果在 onsuccess 中产生。

概述

如此简单,我们已经创建了一个可重用的组件,我们可以将其作为高分屏幕的一部分放入 Space-Truckers 应用程序中!在我们继续下一节之前,让我们回顾一下我们关于 IndexedDB 的学习内容。

IDB 是一种基于浏览器的存储机制,具有存储大量数据的能力。虽然基本存储方式是基于对象的,但 IDB 有包含一组一个或多个对象存储或表的数据库的概念。每个表的架构必须在创建时或架构版本升级时定义。这是通过传递给 indexedDB.open 函数的 currentSchemaVersion 来定义的。当当前版本不存在或低于请求的版本时,onupgradeneeded 事件被触发。

在此事件期间,创建对象存储,定义它们的索引,并填充它们的数据。在升级版本时,在事件处理程序中包含迁移逻辑很重要——否则,数据将会丢失!在我们的情况下,我们不需要迁移分数数据,而且我们不太可能很快需要进行实质性的更改来要求架构更改(请参阅本章末尾的 扩展主题 部分以获取一些可能涉及该操作的想法)。

由于访问 IDB API 的模式本身不支持 Promises,我们已将所需的操作包装在一个支持 Promises 的包装器中。readyPromise 是实际初始化和打开对象存储的地方,也是 onupgradeneeded 逻辑所在之处。一旦 readyPromise 解决,getScoresaddScore 方法就变得可用。这些函数也返回它们各自操作的 Promises,生成分数列表或确认已添加新分数。

我们的 IDB 包装函数是我们为了构建高分屏幕而必须构建的工具——现在,是时候使用它们了。在本章的最后部分,我们将结合我们刚刚学到的内容以及我们关于协程的知识,再加上我们可重用的 GUI DialogBox 组件,来创建一份美味的沙拉,那就是 Space-Truckers:高分榜!

存储和显示高分

沿着我们在上一节结束的烹饪主题,本节是关于将我们的成分组合成一顿饭。所有艰苦的工作和学习都已经发生,所以这一节会很快过去——这样我们就可以开始大快朵颐了!我们将使用playground.babylonjs.com/#VBMMDZ#23上的游乐场作为现场示例——继续跟随或尝试在本节描述和代码片段中提到的示例中复制功能。

重要提示

不要尝试吃掉你的电脑,或者任何不是食物的东西——我们只是在用隐喻,尽管这个隐喻比必要的还要夸张!事实上,让我们完全改变主题。在本节的剩余部分,我们将采用经典的盗窃风格,类似于Ocean’s Eleven

《盗窃案》

仅依靠“肌肉”即DialogBox的不情愿帮助,将分数列表扔到屏幕上并就此结束是不够的。同样,仅引入机智的“破锁匠”SpaceTruckersDb来完成工作也是不够的。如果我们真的想完成这项重大任务,即《高分》,每个分数都需要有一个盛大的亮相。否则,至少它们不应该像一群喧闹的猴子冲击香蕉桶一样同时出现在屏幕上。对于需要记录的新高分,我们需要能够以“传统”的三字母格式收集用户的姓名,这种格式是老式街机柜所使用的。我们需要“智慧”来进行思考。我们需要我们的老朋友协程“为了最后一次工作而‘退休归来’”。

为了以激动人心的子章节总结我们的计划,想象一个引人入胜的蒙太奇序列,展示对《队伍》来说完成这项工作有多困难(实际上,并不困难,但请随我这么说),让我们开始吧。

肌肉

我们将使用DialogBox组件来托管高分显示(参见第九章中的“构建可重用对话框组件”部分,计算和显示得分结果*)。分数本身托管在bodyContainer中。每个分数都是一个仅包含名称和分数属性的对象。getScoreTextLine辅助函数接受一个单独的分数对象,并返回一个可以显示在TextBox中的格式化字符串:


function getScoreTextLine(s) {
    if (!s.score) {
        return s.name;
    }
    let scoreText = s.score.toFixed(0);
    let text = `${s.name}${'.'.repeat
      (20 - scoreText.length)}${s.score}\n`;
    return text;
}

虽然我们期望s.score值存在,但我们仍然检查其缺失,因为添加新分数的过程必然排除了输入姓名的可能性。我们还期望分数是一个完整的整数值,但我们将其转换为固定字符串,小数点后没有零分,以确保安全。返回一个格式字符串,考虑分数值文本表示的长度。

破锁匠

持久性逻辑将由我们在上一节中构建的 SpaceTruckersDb 提供。其函数由 scoreBoardCoro 调用和管理。正如当前主题形式所要求的,关于“撬锁者”的更多内容将不会包含在我们的“大脑”审查中。

大脑

DialogBox 以及输入新得分的编辑模式。当 scoreBoardCoro 作为函数被调用(作为准备运行协程的一部分——见 第九章**,计算和显示得分结果,了解更多),newScore 参数用于传入等待三个字母品牌的新高分。如果存在,则设置 editHighScores 标志,并将一个占位符 scoreToAdd 添加到已由 databaseManager 获取的得分列表中,该列表在进入时立即初始化。同样,nameInputnameInput.onTextChangedObservable 限制了输入长度为三个字符或更少。此外,当检测到按下 Enter 键时,它还会采取行动,将 editHighScores 标志设置为 false


nameInput.onTextChangedObservable.add((ev, es) => {
    if (ev.text.indexOf('↵') >= 0 || ev.text.length >= 3 || 
        ev.currentKey === "Enter") {
            scene.editHighScores = false;
    }
});
// ...
while (scene.editHighScores) {
    yield Tools.DelayAsync(1000);
}

一旦协程完成等待退出编辑模式,如果有 newScore,这意味着用户已经输入了他们的首字母,得分正在等待保存。我们在清理涉及收集用户输入的控制之前完成这项工作:


if (newScore) {
    scoreToAdd.name = nameInput.text.substring(0,3);
    await databaseManager.addScore(scoreToAdd);
    console.log('saved newScore', scoreToAdd);
    virtualKB.disconnect();
    virtualKB.dispose();
    newScore = null;
    nameInput.dispose();
    scoreToAdd = null;
    scores = await databaseManager.retrieveScores();
    await displayScores(scores);
}

在清理了控制之后,我们从存储中刷新 scores 列表,将一切恢复到干净、初始的状态。如果没有 newScore,协程的工作就完成了,工作也就完成了——得分已经在协程执行的开始时检索并显示。有了这样一支技艺高超的团队和精心的准备,对于跟随的人来说,展示得分的任务本身既简短又甜蜜。

工作

组建团队是至关重要的第一步,规划工作内容是第二步,现在到了执行计划的时候了。以下是以下步骤在事件线性序列中的蒙太奇快捷方式:

  1. (yield) 直到“撬锁者”(databaseManager)表示它已准备好。

  2. 从“撬锁者”(databaseManager)获取 scores 列表并将其放入 scores 数组中。

  3. 展示“肌肉”(DialogBox)。等待它完全进入后再继续。

  4. 如果“大脑”表示还有另一个得分要添加(newScore),则会发生以下情况:

    • editHighScores 标志被设置

    • 创建了一个不带名称的占位符得分条目,并将其添加到 scores 列表中

    • 输入元素被放置以收集玩家的首字母(nameInputvirtualKB

    • 一只“小鸟”(即观察者)监听输入元素的变化,当按下 Enter 键或输入了三个或更多元素时,会切换出 editHighScores 模式

  5. “主持人”展示得分(调用 displayScores)。

  6. 在等待编辑标志落下(当editHighScorestrue时使用yield)的过程中,紧张感逐渐升级。

  7. 准备逃跑,但首先,“大脑”扫描新“解放”的分数(scoreToAdd)。

  8. 在跳上逃跑车辆之前,“主持人”上演了一场烟雾弹表演(清除并重新显示存储中的分数)。

  9. 我们看到《船员》团队成功完成工作后走向夕阳。字幕滚动,灯光亮起。

图 13.1 – 在添加或编辑模式下的《太空卡车手》高分排行榜

图 13.1 – 在添加或编辑模式下的《太空卡车手》高分排行榜

目前还没有计划制作续集(…) – 然而,扩展版(导演剪辑版)展示了勇敢的盗贼完成工作后的情况,以及将之前讨论的代码片段与整体《太空卡车手》应用程序整合的细节。

整合

DialogBox组件的美丽之处在于它可以被插入到现有的场景中。这是好事,因为我们希望能够在两个不同的地方显示屏幕 – HighScoreScreen是一个高级包装函数,它实例化和启动ScoreBoard协程,返回scoreBo.dialog实例,以便调用者可以监听其关闭。

这在连接到也新添加的高分按钮的onHighScoreActionObservable中是如何实现的,该按钮由以下选项数据定义:


const highScoreOpts = {
    name: "btHighScores",
    title: "High Scores",
    background: "green",
    color: "black",
    onInvoked: () => {
        logger.logInfo("High Scores button clicked");
        this._onMenuLeave(1000, () =>
          this.onHighScoreActionObservable.
          notifyObservers());
    }
}

这与其他作为MainMenuScene构造函数的_addMenuItems私有函数部分添加的菜单按钮使用的相同模式 – 菜单在通知onHighScoreActionObservable观察者有有趣的事情发生之前淡出了一秒钟。

该观察者的订阅者是在MainMenuScene构造函数的构造函数中设置的,负责设置scoreDialog并在用户点击返回后返回 UI 到主菜单


this.onHighScoreActionObservable.add(async () => {
    this.isTopMost = false;
    let scoreDialog = HighScoreScreen(this.scene);
    scoreDialog.onCancelledObservable.add(() => {
    this._onMenuEnter(1000);
    this.isTopMost = true;
});

我们已经将isTopMost标志引入到MainMenuScene中,以便我们知道是否处理输入(参见MainMenuScene.update函数)或者是否有任何DialogBox实例负责这项任务。一旦我们设置了该标志,我们就通过HighScoreScreen函数显示并获取scoreDialog实例。现在scoreDialog实例可用,我们就可以将其逻辑附加到onCancelledObservable上,该逻辑重新显示菜单并将其设置为处理输入。

类似地,scoringDialog将其处理程序附加到其onAcceptedObservable上,这与MainMenuScreen.onHighScoreActionObservable处理程序所做的一样:


scoreDialog.onAcceptedObservable.add(async () => {
    let score = scoreData.finalScores['Final Total'];
    await scoreDialog.hide();
    let scoreScreen = HighScoreScreen(scene, score);
    scoreScreen.onCancelledObservable.add(async () => {
        await scoreDialog.show();
    });
});

这里的主要区别在于在调用HighScoreScreen之前,我们正在提取Final Total分数值并将其与场景一起传递到函数中,以便它可能被添加为列表中的新条目。

能够通过少量修改现有组件来满足新要求,是软件架构和设计的一个巅峰成就,这使得它成为结束本节的绝佳地方。在本节中,我们概述了持久化和显示高分过程中涉及的事件序列和参与者。现有的DialogBox组件被重用来托管分数板,而本章前面的部分构建的IndexedDB组件提供了存储,ScoreBoard协程则协调一切。

集成最简单的情况是从isTopMost标志到高分屏幕的过渡,这样它就知道在显示对话框时不要处理输入,我们还添加了onHighScoresActionObserver来指示何时切换屏幕。其余的只是将适当的显示和隐藏逻辑连接到各种对话框事件。还能做些什么呢?太多了!请参阅本章末尾的扩展主题部分,以获取一些关于可以贡献的改进想法。

概述

我们本章从关注将我们的应用转变为 PWA 所需的要素以及如何实现开始。我们通过实现应用的高分榜结束本章,并在其中我们学到了很多。让我们回顾一下我们学到了什么。

PWA 是一种应用类型,它模糊了常规网站和传统原生应用之间的界限。与桌面应用一样,PWA 可以在没有网络连接的情况下离线运行。它能够访问宿主计算机的文件系统和硬件设备。此外,与桌面应用一样,PWA 可以通过 App Store(如苹果 App Store、谷歌 Play 或微软商店)发布和部署。与桌面应用不同,PWA 可以通过单个 URL 访问,并以功能减少的常规 Web 应用的形式运行。当遇到不同的限制或约束时,应用会优雅地增强或降低其功能,这使得 PWA 适用于广泛的场景。

一个网络应用程序要被视为 PWA,需要三个元素:用于保护连接的 SSL 托管,用于预缓存和拦截请求的 SW,以及用于定义应用程序元数据的 Web Manifest。在 SSL 下托管网站需要一系列步骤,这些步骤因具体的托管提供商而异,并且非常依赖于特定的托管提供商。例如,Azure Static Web Apps 允许具有自定义域名的网站无需开发者提供或购买证书即可使用 SSL,而 Google 和 AWS 都要求提供额外的基础设施来支持某些或所有 SSL 场景。SW 是运行在浏览器沙箱执行环境中的代码片段。它们可以做各种事情,但在我们的简单应用程序用例中,我们使用它们来进行预缓存和资产的加载。当应用程序从远程 URL 请求特定资源时,SW 会拦截请求并从本地缓存中提供响应,从而允许在不修改任何应用程序代码的情况下实现透明的离线机制。Web Manifest 充当应用程序对任何感兴趣系统的主描述符。它是一个包含一些必需元素和许多可选元素的 JSON 文档,用于将 PWA 打包和发布到应用商店,以及指定 PWA 安装后的外观和感觉。

当涉及到在客户端存储数据的不同方式时,不再需要依赖于如 cookies 之类的机制来持久化任意数量的数据。IndexedDb 浏览器服务为应用程序提供了一个对象存储,可以存储任意大量的数据。尽管使用 IDB 的编程模式并不复杂,但最好通过包装它们以更易于处理的方式与 Promises 集成。我们的需求足够简单,以至于我们不需要在这里使用许多现有的可以与 IDB 一起工作的库,因为我们只需要能够添加一个分数并检索分数列表。

高分屏幕展示了DialogBox组件的美丽与强大,用于显示,IDB 组件用于持久化,以及一个协程来管理所有这些到SpaceTruckerHighScores组件中。DialogBox的通用性使我们能够轻松地将新的高分屏幕集成到 Space-Truckers 应用程序的其余部分。主菜单和得分对话框都为计分板提供托管,允许得分达到标准的玩家使用物理键盘或虚拟键盘输入他们的三个字母的首字母。

可以增强和添加到应用程序中的内容有很多,但开源项目的美妙之处在于任何人都可以为其做出贡献——甚至是你!查看并发布有关游戏或书籍的讨论板上的问题或评论。仓库中列出的问题涵盖了不同的人确定需要解决或实现的各种工作。不同的标签以不同的方式表示和分类问题——例如,“良好入门问题” 标签旨在为新贡献者提供一个简单或直接的任务,以便他们能够轻松上手,并且可以在不讨论的情况下相对容易地完成。查看 github.com/jelster/space-truckers/issues 上的问题列表和 github.com/jelster/space-truckers/discussions 上的讨论。

我们的空间之旅信号已开启,我们正准备进入旅行的最终阶段——我们正接近终点!在我们通过当地街道和十字路口导航到太空码头进行交付的过程中,我们还有一些未了的事宜要处理。下一章将是一个大杂烩,我们将尝试尽可能多地涵盖我们之前未涉及的所有内容。当地导游将帮助我们穿越这些蜿蜒曲折的街道,同时我们将探讨一些前沿主题,如实时光线追踪(路径行进)、WebXR、VR 和 AR 应用程序,以及 Babylon Native,但也会涉及一些重要的实用主题,例如使用 Babylon.js 与 CMS 或电子商务应用程序。请系好安全带——这次旅行还没有结束!

扩展主题

  • 使用 SW 执行除了获取和缓存数据之外的任务。通过将 Space-Trucker 渲染移至 离屏画布,将你的帧率提升到极致。本质上,你将使用 SW 在与 JavaScript 通常卡在的单个主执行线程不同的执行线程上执行实际的渲染工作。Babylon.js 文档在 doc.babylonjs.com/divingDeeper/scene/offscreenCanvas 对此有更详细的说明。

  • 添加一个按钮或键组合,从数据库中清除所有现有得分。这是一个两阶段特性:第一个任务是向 SpaceTruckerDb 添加删除或清除得分的功能,第二个任务是提供一种调用该功能的方法。

  • 动画化列表中每个单独得分的进入效果。如果效果随得分排名而变化,则加分。如果玩家获得高分时能展示一场精彩的烟花表演,则额外加分。

  • 将入口 JavaScript 模块拆分,使着陆页、得分和主菜单在最初加载的模块中,而驾驶和路线规划部分在单独的模块中。这将极大地提高初始页面加载时间,并允许 SW 更高效地获取游戏资源。

第十四章:扩展主题,扩展

这是一章关于结束,但同时也是关于开始。我们在这段漫长的旅程中可能即将到达目的地,但这只是你与 Babylon.js 个人旅程的开始。在这一章中,我们放弃任何线性或顺序进展的假象,而是将在几个不同的主题之间跳跃,每个主题都将为你提供独立的起点,帮助你与 Babylon.js 一同走得更远。

在导航不熟悉的街道时,有一个向导是有用的,这个人对某个地区有深入了解。一个有深厚实践经验的人,知道如何引导游客和新来者到最好的地方和景点。我们的 Space-Dispatcher 已经找到了几位有才华的人,他们将会向我们展示在旅行中我们没有看到或了解到的 Babylon.js 的区域。

在这一章中,我们将参观 BJS 的两个活跃的施工现场。在第一个施工现场,我们将了解将 Babylon.js 的简单优雅从网络直接带到设备硬件上的持续努力——Babylon Native。第二个施工现场涵盖了激动人心的世界(“元宇宙”)——增强现实AR)和虚拟现实VR)的世界,形式为 WebXR——基于 Web 的 AR/VR 应用程序的新标准。

在这些停留之后,我们将遇到我们的第一位向导,BJS 社区成员,论坛上的连续帮助者,Andrei Stepanov,他将带我们通过装卸码头进入 Babylon.js 商场。他将通过一次游览展示如何轻松地将 BJS 与内容管理系统和电子商务平台结合使用,展示最新小工具的璀璨展示。与 Stepanov 先生告别后,我们接下来将访问一个闪亮的新交通枢纽,以便去见我们的最后一位向导,Erich Loftis。

Erich 已经在一段时间的个人旅程中四处游历,他将通过他寻求 3D 图形中真实感圣杯——实时光线(路径)追踪的故事来娱乐和启发我们。这只是即将到来的预览,因为现在是时候向右转,戴上安全帽,我们即将进入我们的第一个 AR 和 VR 施工现场——WebXR

在任何特定的技术领域,总有更多东西可以学习,当话题迅速变化时,这一点加倍或更多。WebXR 是开发基于 Web 的 AR 和 VR 的标准,并且由于其快速发展的标准和支持混合体,它符合“加倍或更多”的政策。当我们学习 WebXR 时,我们不会关注标准的每一个特性——那就像在热浪中试图在山坡上滑冰一样。我们将关注 Babylon.js 的特性和功能,这些特性和功能允许你作为开发者编写利用 WebXR 的应用程序,同时降低这些不断变化的标准和 API 所涉及的风险。

下面是本章我们将涉及的主题:

  • 使用WebXR进行 AR 和 VR

  • Babylon.js Native 项目之旅

  • 将 3D 内容融入网站

  • 路径追踪到高级渲染

使用 WebXR 进行 AR 和 VR

摩尔定律不可阻挡的步伐,以稳定的速度将越来越大的计算能力带入越来越小的微芯片中,已经持续了足够长的时间,以至于普通消费者在智能手机和平板电脑中拥有了惊人的原始计算硅含量。现在的智能手机处理能力已经足够强大,以至于可以设想 AR 和 VR 等场景。

AR 是一个涵盖大量不同用例和场景的应用程序类别。这些场景的共同特征是它们利用设备的摄像头、位置、朝向和其他传感器将 3D 内容嵌入到对现实世界的描绘中。VR 与 AR 非常相似,只是内容不是沉浸在使用者的世界中(现实世界),而是使用者沉浸在内(虚拟世界)。

考虑到 AR 和 VR 体验,重要的是要记住,两者更多的是一个光谱,而不是一个二元性质——没有规则说某物必须使用X百分比的特性才能被认为是 AR 或 VR 应用程序。那将是一个愚蠢的守门人。

注意

如果你正在寻找一个酷炫的乐队名字,现实-虚拟性光谱/连续体都是听起来很酷的选项!更多关于虚拟性光谱的信息,请参阅creatxr.com/the-virtuality-spectrum-understanding-ar-mr-vr-and-xr/

考虑这一点——一个应用程序可能只支持基本的头部跟踪和立体视觉,但它仍然是一个 VR 应用程序。同样,一个简单的应用程序,在视频流中的人像上绘制兔耳朵,从技术上讲也可以被认为是 AR 应用程序。在讨论 Web 开发中的 AR 和 VR 时,大多数情况下都假设重点是 VR 方面。从历史上看,这一点是准确的,但情况并不总是如此。通过考察一些历史背景,我们可以更清楚地了解这种情况是如何发生的,以及何时可以期待这种变化。

互联网上 AR/VR 简史

在广泛的 Web 开发世界中,已经有许多尝试为 VR 内容制定一套标准化的 API,例如VRML标准。最后的但不是最后一次努力被称为WebVR,它旨在为 VR 内容提供支持,几乎没有考虑 AR——这不是因为忽视,而是因为 AR 直到最近才以任何商业可访问的形式存在(我们可以称之为大约 2015 年左右)。

到 2018 年,很明显,为了使 AR 成为一种商业上可行的应用,它需要在网络上运行。问题是欺骗性地简单,但解决起来却非常困难。消费者不希望不得不安装五个不同的应用程序来浏览五个不同的家具店,只是为了在潜在买家的客厅中展示家具选择,但他们很高兴去一个提供同样服务的网站!不幸的是,即使是基本的 AR 也需要访问通常不可用于浏览器 JavaScript 沙盒的设备和传感器数据,而且性能有时也可能不尽如人意。

WebXR 标准于 2018 年由一个涵盖硬件和软件制造商的行业联盟推出。该标准封装和抽象了许多之前 WebVR 标准中遗漏的领域,例如物体/身体部位跟踪、统一控制器界面,这些界面考虑了 AR/VR 可能的多种不同输入,以及一般而言,编程世界级体验所需的一切。所有酷炫的孩子们(苹果、谷歌、Meta/Facebook、三星、微软等)都是这个标准机构的一部分,这意味着开发者和消费者都应该能够从商业 AR/VR 空间的创新爆炸中受益。或者至少应该是这样。专注于 AR 的设备,如微软的 HoloLens,以及专注于 VR 的设备,如 Oculus,已经开始在消费电子市场普及,但支持 WebXR 标准的进展最多只是受到了该联盟最有影响力成员的行动——或者更确切地说,是没有采取行动——的阻碍。

当大多数联盟成员都在忙于实施关键的 WebXR 功能和标准时,其中一位成员——苹果公司——却主要坐在场边。他们最近发布了基于新 iOS 硬件的应用程序软件开发工具包,名为 ARKit,这可能是苹果公司不支持 WebXR 的潜在原因。允许 WebXR 所需的硬件访问将有效地打破 WebKit 对 iOS 网页渲染的铁腕控制。这是令人遗憾的,因为在美国,iOS 大约占 60% 的市场份额,这意味着大多数美国市场对那些希望在网页上开发和提供 AR 体验和产品的公司、个人和组织来说都是不可访问的(相比之下,iOS 在美国以外的全球市场份额不到 30%。Android 拥有海外市场的大部分份额)。在苹果公司方面,情况并没有好太多:截至 2022 年夏季,苹果公司似乎不太可能在接下来的 6 到 12 个月内在其 WebKit 渲染引擎中发布对 WebXR 的支持。

重要提示

待决的反垄断诉讼和立法辩论正在世界各地的许多法院和立法机构中持续进行。有可能这些事项中的一些结果可能导致苹果允许在 iOS 上使用替代网络引擎(如 Chromium)。如果发生这种情况,所有赌注都将无效!

在谈论 WebXR 不支持 iOS、标准不断变化和频繁的破坏性更改这样令人沮丧的话题时,有什么积极的一面?如何保持乐观,为什么你愿意让自己承受这种软件工程上的痛苦?现在让我们大家一起说:因为 Babylon.js 有你的支持——用 WebXR 体验助手将尖锐的痛苦转化为钝痛。

使用 WebXR 体验助手,今天构建明天

Babylon.js 的一个基本原则是向后兼容性至关重要。在 BJS 1.0 上编写 10 年前的代码在 BJS 5.0 中仍然大部分有效,这在谈论技术和网络时真是一项了不起的成就!然而,当处理像 WebXR 这样一个功能和 API 可能迅速出现和消失的东西时,尝试构建一个针对这种移动目标的生产应用还有意义吗?

注意

回顾我们之前关于修辞疑问及其答案的讨论,你应该已经知道那个问题的答案是“YES!”

BJS 的 WebXRExperienceHelper 是一个组件,它确实做了它所说的——即通过设置沉浸式会话所需的所有必要元素来帮助实现 WebXR。提供的 默认体验是为 VR 会话设置的,包括基本的指针跟踪和传送功能,当然,还提供了与 FeatureManager 协作启用、附加和使用其他功能的能力。

理解 FeatureManager 的工作方式的重要概念是启用特定功能的过程——无论是在特定版本、最新版本还是稳定版本——并使其可用于附加到场景。启用功能并将其附加到场景是一个两步过程,包括它们相关的相反操作,如禁用和分离,这是应用程序代码中的两步。对于应用程序来说,但隐藏在引擎盖下的是一系列子操作。例如,在功能启用阶段发生浏览器功能检测、设备能力枚举等。启用过程的结果使 WebXRSession 具有与新启用功能相关的新 Observables。现在,这些 Observables 可用于将这些功能附加到特定场景。

这个概念之所以重要,是因为虽然使用 WebXRExperienceHelperFeatureManager 不是必需的,但这些组件为你的代码提供了从外部变化的影响中隔离自己的关键能力。生产应用程序可以自信地利用用户设备上可用的最新 VR/AR 功能,而不用担心当标准或网络浏览器对标准的支持发生变化时,它们会突然崩溃。提供的高级抽象允许开发者编写、扩展和维护利用尖端浏览器功能的应用程序,同时优雅地降低不支持这些功能的设备的性能。

WebXR 在基于 Chrome 和 Mozilla 的浏览器中提供了许多令人兴奋的功能和能力,尽管一些可能需要用户通过标志“取消隐藏”功能。使用 WebXR 构建的应用程序的类型和功能才刚刚开始被探索,Babylon.js 团队打算全程帮助开发者使用它们。不幸的是,这是我们在这个建筑工地访问的时间——毕竟,还有其他地方要去,还有其他东西要看,我们还有时间表要遵守!

进一步阅读

我们的下一次访问将是前往 Babylon.js 的“元都市”新科技园区。这个园区是 Babylon Native 项目的家——这是一个令人印象深刻、雄心勃勃且特别复杂的任务。在研究其他领域的同时,Native 提供了一个可能的解决方案,以解决围绕 iOS 对 WebXR 支持的问题。让我们在参观 Babylon Native 生态系统校园的过程中了解更多关于 Native 以及该解决方案的样子。

Babylon.js 原生项目的游览

Babylon.js 主要用于作为网络应用程序的一部分,但那并不是它能增加价值的唯一地方。有时,一个应用程序需要使用相同的代码库针对多个平台。其他时候,现有的设备应用程序希望能够轻松地添加与应用程序目的次要相关的 3D 渲染活动(例如,在科学模拟中,渲染器只是将模拟的输出绘制到屏幕上)。具体要求可能包括在包括 iOS 在内的平台上需要 AR 功能。

在这些场景(以及未列出的更多场景)中,Babylon.js 都有机会为应用程序增加价值。通常所说的“Babylon Native”在单一、正确的意义上实际上是一系列适用于特定范围场景的技术。每个场景都是不同的,应该有针对具体情况特定需求的解决方案,而构成 Babylon Native 的技术集允许开发者根据需要选择何时何地应用它们。理解这些技术的一种方法是将它们展示在一个光谱上,一端是完全本地应用,另一端是完全网络原生应用:

图 14.1 – 应用类型光谱。来源:https://github.com/BabylonJS/BabylonNative/blob/master/Documentation/WhenToUseBabylonNative.md

图 14.1 – 应用类型光谱。来源:github.com/BabylonJS/BabylonNative/blob/master/Documentation/WhenToUseBabylonNative.md

前面的图表(摘自 BJS Native 文档,见标题中的链接)是展示 Native 集体的一种方法,它显示了特定组件或框架与本地设备硬件接近的相对规模。

在他关于 BJS Native 技术基础的博客文章中 babylonjs.medium.com/a-babylon-native-backstage-tour-f9004bebc7fb,Sergio 从不同的角度解释了 Babylon Native 部分是如何适应的:

图 14.2 – 在没有 WebGL 的情况下 Babylon Native 的工作分层图。图源:https://babylonjs.medium.com/a-babylon-native-backstage-tour-f9004bebc7fb

图 14.2 – 在没有 WebGL 的情况下 Babylon Native 的工作分层图。图源:babylonjs.medium.com/a-babylon-native-backstage-tour-f9004bebc7fb

无论使用 Babylon React Native 还是简单的 Babylon Native,前面的图表显示了 Babylon Native 的统一抽象层如何覆盖与各种硬件组件(如 BGFX 跨平台图形驱动程序、ARCore 和 ARKit 等其他设备传感器和输入 API 抽象)通信的丑陋和有时混乱的混乱。有了这些概念在心中,我们现在可以考虑一些潜在的用法场景,在这些场景中,仔细查看 Babylon Native 提供的选项是有意义的。

选择 Babylon Native

是否将 Babylon Native 适用于特定项目是一个复杂的问题。Native 的文档有一个专门的页面,包含一个问卷,以帮助您确定哪些方法值得深入研究——哪些则不然——虽然这些信息很有帮助,但通过一个假设的场景可以更好地理解。

如果您的应用程序基于React Native,则有两种集成选项:轻量级集成和完全集成。轻量级选项是使用WebView托管 WebGL 上下文和画布。这有一个优点,即能够利用 JavaScript 的即时编译JIT),这意味着在某些平台上,JS 代码将比不使用 WebView 时更快。完全集成选项是使用Babylon React Native。以下是我们可以想象的应用程序可能的样子。

巴比伦本地应用的演变

LARP 玩家应用程序是一个为现场动作角色扮演者(Live Action Role Players)设计的应用程序——这些人喜欢将桌面游戏中的桌面拿掉,并使用应用程序自己进行游戏,通过应用程序协调事件、聊天等,拥有人们从现代 Web 应用程序中期待的所有不同奢华功能。“玩家应用程序”使用 React 构建,并一直保持着稳定的发布,增强和扩展了网站的功能。应用程序的创建者希望允许活动调度员能够离线管理活动(因为有时活动空间没有信号),所以他们增加了 PWA 功能,让每个人都感到满意。

然后,有一天,一些 LARP 玩家在玩宝可梦 GO时突然意识到,虽然 LARP 很酷,但更酷的是用 AR 进行 LARP!玩家将能够看到他们施展的法术的视觉效果,通过技能检定来探测陷阱,并在一个被赋予生命的幻想世界中四处探索。他们现有的 LARP 工具包括一些嵌入到物品(例如,一把剑)中的自制的蓝牙连接设备,通过闪烁或蜂鸣来注册命中和类似的游戏管理任务,但这只是其中的一部分。许多成员拥有 iOS 设备,而其他人则使用 Android,甚至还有一些人坚持使用经过大量修改的 Windows Mobile 版本(愿他们的灵魂得到祝福)。2021 年,该小组在一项 Cosplay 比赛中获得了一等奖,这为他们提供了足够的资金来购买一套HoloLens头戴设备和一套Oculus VR设备,供一位因健康问题无法亲自参加活动的成员使用。增强现实功能的玩家应用程序需要能够与这些设备通信,以便有用,并利用玩家应用程序中的现有功能(例如,显示玩家的库存)。最后,该小组开发了一个自定义的 C#桌面应用程序,他们称之为“GM 应用程序”,用于连接这些蓝牙设备,并作为游戏的裁判(通常称为GM游戏大师)。应用程序的维护者有机会以有价值且明确的方式逐步将应用程序演变为其愿景:

  1. 将应用迁移到React Native应用程序中,该应用程序的行为与当前完全相同。

  2. 使用 Babylon 在WebView中添加基本的渲染功能。这将允许团队以与 Web 应用程序相同的代码库发布相同的功能。

在 BT 和 WiFi 设备之间建立本地网格连接,将数据输入到 React Native 应用程序中。

  1. 在 C# 应用程序中集成 Babylon Native 的纯 3D 场景渲染,以向 GM 展示不同的动作视图(想象一下一场剑斗,剑上嵌入了传感器,场景通过传感器传达剑的状态)。

  2. 将渲染责任从 WebView 转移到 Babylon React Native。使用 Babylon.js 与 WebXR 结合,利用设备功能将场景渲染到实时图像流或远程位置的 VR 设备上。

  3. 享受 LARPing(生活体验角色扮演游戏)的乐趣!

这个示例并不旨在全面或详尽,但它通过暗示涵盖了相当广泛的潜在用例。当开始一个原生项目时,考虑是否可以使用不同的框架(如 Unity 或 Unreal)更容易地实现相同的目标是值得的。同时,也要记住,在撰写本文时,项目的当前(2022 年夏季)状态仍然不够成熟,因此存在功能支持的局限性和空白。请查看下一节中的链接,以获取 Babylon Native 支持和未支持功能的最新信息。

进一步阅读

由于项目正在快速演变,文档也在不断更新。以下是一些链接,您可以从中了解更多关于 Babylon Native 和 Babylon React Native 的信息:

尽管时间很短,但我们对 Babylon Native 校园的概述已经涵盖了该区域各个小径上标记的重要指南和标志。作为一个技术集合,Babylon Native 主要是关于根据不同情况选择合适的工具。已经使用 React 或使用 React Native 的 Web 应用程序是目前最稳定和最先进的实现,但如果您想构建在 iOS 上运行的 AR 应用程序,Babylon Native 是您应该遵循的道路。每种方法都有其优点和缺点,有些可能相当重要。好消息是,无论选择哪种方法,您编写的与 Babylon.js 交互的代码在多平台目标场景中不需要更改。

接下来,我们将与我们的第一位指南安德烈·斯捷潘诺夫(Andrei Stepanov)一起处理业务。安德烈已经与 Babylon.js 和 内容管理系统CMSs)合作很长时间了,因此他是给我们快速浏览 BJS 如何用于电子商务和 CMS 商业场景的完美人选。

将 3D 内容集成到网站中

当涉及到理解如何在现实世界的以客户为中心的业务场景中使用 Babylon.js 时,没有比 Andrei 更有知识的人了,他在 BJS 社区论坛上以“Labris”的名字发帖。作为 MetaDojo 的资深 3D 开发者(metadojo.io),他通过构建符合规格的 3D 体验来满足和取悦客户。Andrei 不仅满足于仅仅谈论如何使用 Babylon.js 构建和创作,他还是 BabylonPress 网站的建设者(babylonpress.org),该网站展示了使用 BJS 与 WordPress 内容管理系统结合的不同示例和模式。

Babylon.js 和 CMS

Babylon.js 让我们从头开始构建非常复杂的 JS 3D 应用程序。同时,有许多情况需要将 Babylon.js 集成到已经存在的网站(带有 CMS)中——这是一个允许用户创建、编辑、发布和存储数字内容的应用程序——或者只是某些 HTML 模板。

有许多不同的方法可以实现这一点,在不同的层面上。它们将取决于具体需求,特别是“3D 用户体验”,你需要提供。由于不同 CMS 的数量和种类众多,我们无法在这里描述所有可能的解决方案,因此我将在接下来的几个小节中仅解释一些最常见解决方案和途径。

Babylon Viewer

Babylon.js 拥有一个官方扩展,名为 Babylon Viewer,这可能会简化许多集成所需的时间。它甚至有自己的 HTML 标签,<babylon></babylon>,在其中你可以定义所有需要的参数。

要在准备好的环境中显示 3D 模型——其中已经调整了灯光、阴影、反射等——你只需向查看器添加一个脚本引用,如下所示:


<script
  src="img/babylon.viewer.js">
</script>

然后,添加一个 <babylon> 标签,并将模型属性设置为指向 .gltf.glb 文件:


<babylon model="model.gltf"></babylon>

除了 .gtlf.glb 格式外,还有 .babylon.obj.stl 格式。它的简单性使得 Babylon Viewer 能够轻松集成到任何 CMS 中,使其成为需要在大用户可编辑的 CMS 中显示大量不同 3D 模型(电子商务、游戏网站和 3D 艺术家博客)的理想选择。有关不同 Babylon Viewer 配置的更多信息,请参阅此处:doc.babylonjs.com/extensions/babylonViewer/configuringViewer

Babylon Viewer 3D WordPress 插件

基于 Babylon Viewer 的基础,还存在一个社区扩展:Babylon Viewer 3D WordPress 插件。这允许你使用 Shortcode 来显示 3D 模型和 3D 场景:


[babylon]model.gltf[/babylon]

你可以在 Babylon-wordpress-plugin 的 GitHub 主页上的 README 文件中使用 3D 查看器 github.com/eldinor/babylon-wordpress-plugin

亭式模式和 Iframes

关于 iframe 实现,值得提一下的是,Babylon 沙盒(sandbox.babylonjs.com/)有一个特殊的“展台”模式,允许您使用其功能与任何适当格式的 3D 模型。例如,看看这个美丽的例子(琥珀中的古蚊 3D 模型)——在Khronos Group文章中关于GLTF透明度的例子:www.khronos.org/news/press/new-gltf-extensions-raise-the-bar-on-3d-asset-visual-realism

URL 中嵌入的不同查询字符串元素允许内容创建者或管理员定义源 3D 文件以及所有其他参数,例如相机位置、自动旋转行为、天空盒和环境纹理。

要使用“展台模式”,请根据以下表格定义 URL。第一个参数在sandbox.babylonjs.com/之后以?开头;所有其他参数在参数之前以&开头。另外请注意,由于 Babylon.js 是一个开源项目,您可以创建并托管自己的 Sandbox 版本!

表 14.1 – BJS 沙盒 Iframe 参数表

表 14.1 – BJS 沙盒 Iframe 参数表

最后,您将得到一个像这样的 HTML 链接——相当长的链接:

sandbox.babylonjs.com/?kiosk=true&assetUrl=https://raw.githubusercontent.com/wallabyway/gltf-presskit-transparency/main/docs/MosquitoInAmber_withRefraction.glb&cameraPosition=-0.14,0.005,0.03&autoRotate=true&skybox=true&environment=https://assets.babylonjs.com/environments/studio.env

BJS 游乐场和 Iframe

另一个特别有用的选项是直接从 Babylon 游乐场显示场景的特殊 HTML 模板。只需在游乐场 URL 之前添加frame.xhtml,它就会以全屏显示渲染区域,但底部工具栏会显示 FPS、重新加载和编辑按钮。

这里有一个例子:www.babylonjs-playground.com/frame.xhtml#6F0LKI#2

要仅显示渲染区域,请使用full.xhtml作为前缀。有关 Playground URL 格式的更多信息,请参阅此处:doc.babylonjs.com/toolsAndResources/tools/playground#playground-url-formats。此选项的结果是您可以然后使用该 URL 作为 iframe 图像元素的源 – 请参阅developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe了解如何定义 iframe 元素。

Babylon.js 在 CMS 中

最后,如果您正在寻找 Babylon.js 和 CMS 之间更紧密的集成,您需要考虑以下通用步骤。确保您有以下条件:

  • Babylon.js 脚本已正确加载。根据 CMS 的不同,如果网页上要显示 3D 内容,您也可以有条件地加载 Babylon.js。

  • CMS 支持上传 3D 文件(大多数现代 CMS 都有有限的允许文件扩展名)。

  • 需要一个合适的 canvas 元素来显示。为每个 Babylon canvas 分配一个唯一的 ID 是有意义的(例如,借助帖子 ID 或其他 CMS 变量)。

  • Canvas 和 BJS 引擎元素已正确连接,以响应调整大小。

在这里,应用的复杂性和规模仅取决于您的创造力。服务器端语言可以在将其交付给 JS 客户端之前预处理任何所需的数据,使我们能够构建一个真正的 3D CMS,其中所有用户体验和交互都在 3D 空间中发生。

Babylon.js 不仅仅是一个用于二维网站的 JavaScript 框架;它是构建多用户 3D 世界和元宇宙的关键组件之一,至少按照当前这个术语的含义来说。

在单个网页上加载 3D 模型与在许多不同的网页上加载可能的大量 3D 模型之间有很大的区别。管理和变更过程的管理至关重要,但在 Andrei 的指导下,您将准备好面对这些挑战以及更多。现在,电子商务或 CMS 应用中的 3D 内容与大约 50 年前的照片真实渲染技术有什么关系呢?当然,是 Babylon.js!是时候继续我们的旅程,从高度实用的 3D 编程转向高度实验性的方面了。

路径追踪到高级渲染

我们“扩展主题,扩展”之旅的最后一站是与音乐家、工程师和图形奇才 Erich Loftis。他将引导我们了解他实现实时路径追踪RTPT)与 Babylon.js 的故事。RTPT——也被称为光线追踪或简称RT——是一种基于路径追踪的渲染技术,Nvidia 和 AMD 等公司才刚刚开始在 AAA 商业游戏中提供,而且仅以特定的方式提供。通过重述 Erich 的旅程,为什么这项技术在实时游戏和模拟中如此难以实现的原因可能会变得非常清晰。

Erich Loftis 的《光线追踪及其历史》

RT 是一种在计算机上渲染逼真图像和效果的技术。它遵循光学定律,并模拟了物理光束在现实世界中的行为。因此,RT 可以产生真正照片般的图像。RT是照片级离线渲染的标准。因为它已经进入实时应用和游戏(其中光栅化是无争议的王者),因此了解它内部的工作原理至少是基础性的。

通过利用强大的Babylon.js引擎,我们可以利用这种理解来创建自己的基于物理的渲染器,它可以直接在浏览器中运行。这很重要,因为它打开了在任何平台或设备上体验照片级图形的大门,甚至包括你的手机。当然,到达这个点的旅程并不完全是最直接或容易的,但正如你将从演示和示例中看到的那样,努力是完全值得的!

我的 RT 之旅

正是体验RT在所有设备上的梦想促使我创建了一个three.js的路径追踪渲染器,始于 2015 年。在过去的 7+年里,我一直在缓慢但稳步地进行研究、构建、改进和优化一个基于浏览器的渲染器,它不仅能够产生高质量的、照片般的图像,而且旨在每秒 30-60 帧的速度实现这一点!请查看我在GitHub上持续进行的项目,在那里你可以尝试数十个可点击的演示:github.com/erichlof/THREE.js-PathTracing-Renderer

一段时间以前,在 2020 年,一位 Babylon.js 开发者联系了我,问我是否可以为他们制作一个类似的渲染器。我必须在这里声明,我多年来主要使用three.js,但我一直钦佩并印象深刻的是令人惊叹的Babylon.js库。当我同意将我的光线/路径追踪系统从 three.js 移植到 BJS 时,我对 BJS 论坛社区同样印象深刻。他们非常友好和乐于助人,是了不起的人!没有他们的帮助和支持,我们无法让 BJS 渲染器运行起来。因此,在我们深入之前,我要向大家快速致谢——谢谢,BJS 社区!

RT 或光栅化?

要在 BJS 和浏览器中实现交互式、实时 RT 需要什么?首先,让我们快速了解一下渲染 3D 图形的两种主要技术。一旦我们了解了它是如何工作的,我们也会看到为什么我们想要尝试使用 BJS 来尝试这条 RT 路线。

当涉及到在 2D 屏幕上显示 3D 图形时,有两种主要方法:光栅化RT。简而言之,光栅化通过首先将场景几何形状(以 3D 顶点的形式)投影到屏幕上,然后以许多平坦的 2D 三角形的形式呈现。任何占据设备显示屏幕三角形区域的像素都会被发送到像素着色器(也称为片段着色器)。当片段着色器在像素上运行时,会计算其最终的显示颜色。所有这些彩色像素组成了我们在设备上看到的最终图像。

相比之下,RT 通过首先处理显示上的每个像素来渲染图像。对于屏幕上的每个像素,都会构建一个从相机位置开始的几何射线。从相机指向,这个相机射线然后向其像素所在的位置(通常是你的屏幕)发射。穿透目标像素后,相机射线继续进入 3D 场景。然后它会模拟现实世界中物理光射线如何与其环境相互作用。

只有在管线中的这个阶段,我们才会考虑场景几何形状。每个相机像素射线都会与场景中的每个 3D 形状进行交点测试。射线击中表面时,记录该位置的颜色和光照,然后产生一个“反弹”射线,并沿新方向发送。这个方向由击中表面的材料属性决定。此外,反弹射线必须像其父射线一样检查整个场景几何形状(每个 3D 形状或三角形)的任何交点,因此无论你愿意等待多长时间,都会重复整个过程。当像素的相机射线及其产生的反弹射线与场景完成交互后,光线追踪器会报告该像素的最终颜色。就像光栅化一样,我们最终在屏幕上得到一满屏的彩色像素,但到达这些结果所采取的路径完全不同!

这两种渲染方法在真实感和速度方面都有权衡。光栅化(占所有 3D 图形的 99%)拥有完整的 GPU 硬件支持,因此非常快且高效。然而,也存在一个缺点。一旦 GPU 完成将场景的三角形投影和光栅化到 2D 屏幕上,周围 3D 场景的信息就会丢失。为了检索这些丢失的全局场景信息,必须使用复杂的技术,如光照贴图、阴影贴图、反射探针等。换句话说,需要大量的图形知识和额外的工作才能接近 RT 质量的视觉效果。

另一方面,RT 可以自动生成最真实的图形,直接从盒子里出来!在光栅化中难以实现甚至不可能实现的光照效果,在 RT 算法中自然地出现。然而,截至 2022 年,RT 并未被大多数 GPU 硬件广泛支持。所有 CPU 都可以运行 RT 程序,但 CPU 并不是为了大规模并行而设计的。因此,与 GPU 上的硬件加速光栅化相比,基于传统 CPU 的软件 RT 非常慢。即使 RT 软件被移动到完全在 GPU 上运行的着色器中(正如我们在这里的项目中将要做的),在该着色器中仍需要进行几个 RT 算法优化,并且/或者如果希望以交互式帧率实现 RT,还需要一个不错的加速结构,例如边界体积层次BVH)。

选择 RT 路径

因此,在事先了解这些权衡(以及一些直到我项目进行多年后才了解的权衡——哈哈!),我决定选择 RT 路径。现在,我将快速跳到我开始使用 Babylon.js 作为宿主引擎实现 RT 的时候。我将概述必要的设置,以及一些代码片段来展示一些实现细节。让我们直接进入正题!

由于我们现在正在遵循 RT 方法,我们必须找到一种方法从相机构建一个视锥体射线穿过屏幕上的每一个像素。获取屏幕像素的一个常见方法是创建一个全屏后处理效果,或者简称为后处理(正如你在第十章通过光照和材质改善环境中学到的)。由于后处理是一个常见操作,BJS 有一个非常方便的库包装器,它为我们处理了所有的WebGL样板代码和后处理设置。在 BJS 中,这个辅助工具被称为EffectWrapper。以下是一个典型的后处理创建示例:


const { Effect, RenderTargetTexture, Constants } = BABYLON;
const store =
  Effect.ShadersStore["screenCopyFragmentShader"];
const screenCopyEffect = new EffectWrapper({
     engine: engine,
     fragmentShader: store,
     uniformNames: [],
     samplerNames: ["pathTracedImageBuffer"],
     name: "screenCopyEffectWrapper"
});

现在,这里的设置变得有点棘手,不是因为pathTracingEffect),我们在所有像素上进行光线追踪并保存它们的颜色结果,通过使用渲染目标纹理RTT):


const pathTracingRenderTarget =
    new RenderTargetTexture("pathTracingRenderTarget",
     {width, height}, pathTracingScene, false, false, 
     Constants.TEXTURETYPE_FLOAT, false,
     Constants.TEXTURE_NEAREST_SAMPLINGMODE,
     false, false, false, Constants.TEXTUREFORMAT_RGBA);

这个大的screenCopyEffect(然后反馈到下一个动画帧的第一个后处理pathTracingEffect)。现在,我们的 GPU 光线追踪器可以使用其先前的结果(它自己的像素颜色历史)与它目前从 RT 计算的新鲜像素颜色结果进行混合。换句话说,它不断地与自身混合和混合。经过大约几百帧,这个 ping-pong 反馈过程将迅速产生非常平滑的抗锯齿结果,似乎在我们眼前神奇地收敛!渲染设置的最后一块拼图是一个最终的监视器输出后处理(命名为screenOutputEffect)。它的任务是执行噪声过滤,然后是色调映射(你可以在第十章色调映射和基本后处理部分了解),最后是一些伽玛校正(也在第十章色调映射和基本后处理部分了解),以在数字监视器和屏幕上产生更令人愉悦的颜色输出。

总的来说,我们需要总共三个后处理效果:

  • pathTracingEffect: 这个效果会在每个单独的像素上进行所有 RT 计算。它会使用后续的screenCopyEffect提供的任何像素历史信息来与自身进行混合。它输出到RenderTargetTexture (RTT),最终被传递给后续的后处理。

  • screenCopyEffect: 这个效果接收前面后处理提供的 RTT 输出,并将其复制/保存到自己的 RTT 中。然后,它将这个保存的副本发送回前面的pathTracingEffect以用于与自身混合。

  • screenOutputEffect: 这个后处理负责屏幕的最终颜色输出。它接收前面的pathTracingEffect RTT(其中包含迄今为止所有经过精细处理、ping-pong混合的光线追踪像素结果),应用其特殊的过滤器和对像素颜色的调整,然后直接输出到屏幕。

注意

前两个效果组成了ping-pong 缓冲区,或者说是反馈循环。

现在我们已经为我们的自定义系统设置了逐步细化我们的光线追踪图像的功能,并且可以正确地显示最终的像素颜色输出,我们只需要做最后一件事——实际的 RT!让我们暂时转换一下思路,简要讨论一下 RT 和路径追踪PT)之间的相似之处和不同之处,以及我们的光线追踪器/路径追踪器在浏览器中施展魔法需要什么。

PT 之路

为了最好地理解 RT 和 PT 之间的关系,让我们简要回顾一下 CG 历史中 RT 发现和技术的发展历程。在 1968 年,亚瑟·阿佩尔发明了光线投射(Ray Casting),这是一种开创性的技术,其中数学光线从摄像机通过每个像素射出。这些摄像机光线在 3D 场景中首先击中的物体决定了我们在图像中看到的内容。然后,在 1979 年,特纳·惠特德发明了 RT,它依赖于阿佩尔在 1968 年之前的光线投射技术,但通过遵循光学定律多次递归地执行,以捕捉从镜面(镜子、玻璃等)表面反射和折射的物理上准确的反射和折射。接着,在 1986 年,詹姆斯·嘉吉亚发明了 PT,这是 RT 的最终演变。在所有之前的 RT 技术基础上,嘉吉亚添加了蒙特卡洛积分(随机采样和平均),以随机采样材料 BRDFs(特别是漫反射表面),以捕捉诸如焦散和相互反射的漫反射表面“反弹照明”等物理光效。PT 的名字来源于追踪(随机采样)光线在场景中与不同类型材料相互作用时可能采取的所有可能路径,然后收集所有这些光路径的贡献,以产生一个真实、逼真的图像。

看了这个简化的 RT/PT 历史,希望你能看到 PT 是如何与 RT(以及之前的光线投射)相关联、演变并改进的。由于我想要最逼真的图形效果,我选择了更复杂的蒙特卡洛 PT方法(1986 年嘉吉亚风格),它能够捕捉到光栅化甚至更老式的 RT 无法实现的灯光效果。而且,多亏了我们努力建立逐步精炼的后处理效果系统,我们随机采样的蒙特卡洛PT 结果可以正确地平均和随着时间的推移进行细化,最终形成真实图像。这基本上意味着在你的浏览器中实现逼真的渲染!

浏览器中的 PT

现在,让我们来讨论场景几何以及 PT 在定义场景方面需要什么。我们有两种方法来告诉 PT 片段着色器场景中有什么。第一种也是最简单的方法是在片段着色器本身中简单地编写一个 GLSL 函数,将整个场景的几何形状作为着色器的一部分来定义。所有对象/形状都是硬编码的,并依次列出。如果场景中形状/对象的数量不超过 20 个左右,这没问题,但一旦你涉及到数百个对象,或者更糟糕的是,使用一个典型的具有数千个三角形(每个三角形都被每条射线测试!)的模型,我们的路径追踪器就会停止工作。为了极大地加快速度并保持 PT 的交互性,我们需要使用加速结构,例如BVHBVH基本上就是一个紧密包围三角形模型(s)的边界框的二叉树。在测试交点时,如果射线错过了一些较大的边界框,它们可以跳过模型的大部分区域。要了解如何构建 BVH,请查看我在github.com/erichlof/Babylon.js-PathTracing-Renderer/blob/main/js/BVH_Fast_Builder.js上的自定义 BVH 构建器代码。回想一下,路径追踪器(在片段着色器内部)必须能够访问整个场景,而且由于我们无法将包含数千个三角形的绝大多数大型场景放入着色器uniforms(大多数显卡都有一个硬性限制),我们将 BVH 及其所有边界框紧密打包到一个数据纹理中。这个 BVH 纹理将使我们的 GPU 路径追踪器能够快速轻松地访问整个优化后的场景几何形状(通过简单的纹理查找)。

接下来,所有的光线追踪器和路径追踪器都需要一个形状交点库来进行与各种原始形状的射线交点测试,例如球体、盒子和三角形。在 RT 刚刚出现的时候,计算机的速度足够快,可以与简单的数学形状相交。这些形状的例子包括球体、圆柱体、圆锥体和平面,它们都属于称为二次曲面的形状类别。射线与这些二次曲面形状相交的解决方案是通过简单地解该形状的二次方程来处理的。这就是为什么当你查看更早期的光线追踪图像时,场景中只包含棋盘格平面和不同大小和材料的球体(或其他二次曲面)。在 RT 的早期几年,对于与更复杂的三角形几何体(如我们今天使用的)相交的数学是相当理解的,但需要很多年计算机才能足够快,能够处理与整个由数千个三角形组成的多边形 3D 模型进行射线测试。在过去的 7 年里,我收集了几乎所有我能找到的用于确定射线与各种形状相交的例程。以下是我PathTracingCommon.js文件的链接,其中包含所有这些交点例程:github.com/erichlof/Babylon.js-PathTracing-Renderer/blob/main/js/PathTracingCommon.js。同样重要的是,这些处理蒙特卡洛 PT风格不同光源类型(点、聚光灯、方向性、区域和 HDRI)和材料类型(来自第十章章节 10,通过光照和材料改善环境)的函数也包含在这个库文件中。

进一步阅读

嗯,很遗憾,在这篇更通用的概述风格文章中,没有足够的空间来详细说明我的GLSL PT 着色器代码(所有 RT/PT 算法都在这里发生)。然而,如果你想看看一些 GLSL 中 RT/PT 的精彩示例(我也从中学习了很多),请查看 Shadertoy 上的这些着色器:

如果你想要深入探讨 RT 和 PT 的理论与实践,我认为没有比 Scratchapixel 更好的资源了。这个惊人的网站包含了你需要了解的一切关于光栅化、RT、PT 以及一般图形的知识:www.scratchapixel.com/.

最后,为了看到这篇文章的所有部分融合在一起,请查看 Babylon.js 路径追踪 渲染器:github.com/erichlof/Babylon.js-PathTracing-Renderer

这是我们正在进行的项目,其中包含几个可点击的演示,展示了 PT 的不同领域。与 Space-Truckers OSS 项目一样,这个 BJS 路径追踪 渲染器项目对 Pull Requests 开放。如果你开始涉足这个令人着迷的实时渲染(RT)和路径追踪(PT)的世界,我们非常乐意看到你的贡献!不过,有一个警告——一旦你走上了 RT 和 PT 的道路,就很难停下来!

享受渲染!

摘要

在我们穿越 BJS 元都市 的旅程中,我们看到了许多新事物。我们听说了一些正在建设中但已准备营业的新奇事物,例如带有 WebXRVRAR。为了帮助开发者利用这些奇迹,我们了解了 Babylon.js 提供的 WebXRExperienceHelper。与 FeaturesManager 协同工作,它允许开发者有信心地针对快速演变和变化的标准进行编码。

Babylon.js 是一个将向后兼容性作为其基石原则之一的项目,因此随着硬件的改进——或者更多产品向 WebXR API 开放其硬件——功能将随着浏览器供应商添加支持而“点亮”。虽然今天将 iOS(以及 WebKit)包括在支持的软件列表中将是件好事,而且虽然我们可以哀叹一个本可以存在的世界,但使用 Babylon.js 的应用程序将准备好在那天最终到来时充分利用这一机会。

直到那时,开发者和设计师有几种潜在的方法,这些方法理想上允许最大的代码重用和最低的摩擦来实现和维护。Babylon.js Native 项目是一系列工具和技术,跨平台或原生项目的工作者可以利用这些工具和技术来获得最大的生产力和效率。这些工具涵盖了从全功能的裸机 BJS Native 到我们熟知并喜爱的“纯”BJS 的光谱。在两者之间,Babylon React Native 为已经使用 React 和 React Native 的开发者提供了一种将 BJS 集成到他们的应用程序中的方法,而在光谱的另一端,将 WebGL 上下文托管在 WebView 中为在任意软件应用程序中集成潜在的本地设备应用程序提供了另一条途径。

Babylon.js 不仅仅关于制作像《太空卡车》这样的游戏。作为一个通用的 3D 应用开发平台,BJS 为我们打开了整个宇宙的可能性,等待好奇的探险者去解锁。也许那些好奇的探险者中会有你!每一枚硬币都有其另一面,拥有如此多的可能性意味着很难在与其他《太空卡车》旅程的相同背景下,对其中更有趣的部分给出很好的描述。这正是我们的两位向导发挥作用的地方。作为 BJS 其他一些领域的长期探险者,Andrei Stepanov 和 Erich Loftis 有很多东西可以与社区分享。

通过他的Babylon Viewer 3D WordPress 插件以及他广泛而详细的示例网站babylonpress.org,该网站展示了查看器,Andrei 让我们看到了如何使用短代码将 3D 模型作为内容编辑器包含进来,一旦在 CMS 页面中注入了适当的脚本引用。通过告诉我们他进入 PT/RT 的旅程,Erich Loftis 反过来也让我们看到了图形渲染技术的创新历史以及它们在计算机图形世界中的应用。

他们每个人都为我们提供了他们各自主题的独特见解和方法,帮助我们指引到这本书的终点站。尽管这是旅程的结束,但它只是另一段旅程的开始。然而,与这本书不同的是,这段新旅程——你的旅程——并没有被记录或写下来,也没有任何预先确定的路线。这条路线将走向何方,它包含什么,完全取决于你。无论目的地是笼罩在雾中还是被灯塔照亮,你并不孤单。BJS 社区在那里帮助你、支持你,当然,引导大家。BJS 论坛forum.babylonjs.com是提问、遇见像 Erich 和 Andrei 这样的人,以及从其他社区成员那里学习的好地方。

祝你在旅程中好运——基于 Web 的 3D 世界和 BJS 社区都在等待着你!

posted @ 2025-10-27 09:12  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报