构建你自己的-2D-游戏引擎-全-
构建你自己的 2D 游戏引擎(全)
原文:Build Your Own 2D Game Engine and Create Great Web Games
协议:CC BY-NC-SA 4.0
一、使用 JavaScript 开发 2D 游戏引擎的简介
视频游戏是复杂的、交互式的多媒体软件系统。他们必须实时处理玩家输入,模拟半自动对象的交互,并生成高保真的图形和音频输出,同时努力吸引玩家。由于需要精通软件开发以及如何创建吸引人的玩家体验,构建视频游戏的尝试可能会很快变得势不可挡。第一个挑战可以通过一个软件库或游戏引擎来缓解,它包含一个专门为开发视频游戏而设计的实用程序和对象的一致集合。玩家参与的目标通常是通过仔细的游戏设计和整个视频游戏开发过程中的微调来实现的。这本书是关于一个游戏引擎的设计和开发;它将专注于实现和隐藏引擎的日常操作,同时支持许多复杂的模拟。通过本书中的项目,你将构建一个实用的游戏引擎,用于开发可通过互联网访问的视频游戏。
游戏引擎使游戏开发者不必执行简单的例行任务,例如解码键盘上的特定按键,为常见操作设计复杂的算法,例如模仿 2D 世界中的阴影,以及理解实现中的细微差别,例如加强物理模拟的精确度容差。商业和成熟的游戏引擎,如 Unity 、虚幻引擎和 Panda3D 通过图形用户界面(GUI)展示他们的系统。友好的 GUI 不仅简化了游戏设计的一些繁琐过程,例如在关卡中创建和放置对象,而且更重要的是,它确保了这些游戏引擎可以被具有不同背景的创意设计师访问,这些设计师可能会发现软件开发细节令人分心。
这本书关注独立于 GUI 的游戏引擎的核心功能。虽然全面的 GUI 系统可以改善最终用户的体验,但实现要求也会分散游戏引擎的注意力并使其复杂化。例如,关于在用户界面系统中实施兼容数据类型的问题,例如限制来自特定类的对象被分配为阴影接收器,对于 GUI 设计是重要的,但是与游戏引擎的核心功能无关。
这本书从两个重要方面探讨了游戏引擎开发:可编程性和可维护性。作为一个软件库,游戏引擎的接口应该便于游戏开发者通过良好抽象的实用方法和对象进行编程,这些方法和对象隐藏了简单的例行任务并支持复杂但常见的操作。作为一个软件系统,游戏引擎的代码库应该通过设计良好的基础设施和组织良好的源代码系统来支持可维护性,从而实现代码重用、持续的系统维护、改进和扩展。
本章描述了本书的实现技术和组织。讨论引导您完成下载、安装和设置开发环境的步骤,指导您构建您的第一个 HTML5 应用,并使用这第一个应用开发经验来解释阅读和学习本书的最佳方法。
这些技术
建立一个游戏引擎的目标是允许游戏在万维网上可访问,这是由免费可用的技术实现的。
几乎所有的网络浏览器都支持 JavaScript,因为世界上几乎每台个人电脑上都安装了解释器。作为一种编程语言,JavaScript 是动态类型的,支持继承和作为一级对象的功能,并且易于与完善的用户和开发人员社区一起学习。有了这项技术的战略选择,任何人都可以通过适当的网络浏览器在互联网上访问基于 JavaScript 开发的视频游戏。因此,JavaScript 是为大众开发视频游戏的最佳编程语言之一。
虽然 JavaScript 是实现游戏逻辑和算法的优秀工具,但软件库或应用编程接口(API)形式的附加技术对于支持用户输入和媒体输出需求是必要的。HTML5 和 WebGL 的目标是构建可以通过网络浏览器访问的游戏,它们提供了理想的补充输入和输出 API。
HTML5 旨在通过互联网构建和呈现内容。它包括详细的处理模型和相关的 API 来处理用户输入和多媒体输出。这些 API 是 JavaScript 自带的,非常适合实现基于浏览器的视频游戏。虽然 HTML5 提供了基本的可缩放矢量图形(SVG) API,但它不支持视频游戏对实时照明、爆炸或阴影等效果的要求。Web Graphics Library (WebGL)是一个 JavaScript API,专门用于通过 Web 浏览器生成 2D 和 3D 计算机图形。凭借其对 OpenGL 着色语言(GLSL)的支持以及访问客户端计算机上图形处理单元(GPU)的能力,WebGL 能够实时生成高度复杂的图形效果,非常适合作为基于浏览器的视频游戏的图形 API。
这本书是关于游戏引擎的概念和开发,JavaScript、HTML5 和 WebGL 只是实现的工具。本书中的讨论集中在应用技术来实现所需的实现,并不试图涵盖技术的细节。例如,在游戏引擎中,继承是通过基于对象原型链的 JavaScript 类功能实现的;然而,没有讨论基于原型的脚本语言的优点。引擎音频提示和背景音乐功能基于 HTML5 AudioContext 接口,但其功能范围并未描述。游戏引擎对象是基于 WebGL 纹理贴图绘制的,而 WebGL 纹理子系统的特性并没有呈现。技术的细节会分散对游戏引擎的讨论。这本书的主要学习成果是游戏引擎的概念和实现策略,而不是任何技术的细节。这样,读完这本书后,你将能够基于任何一套可比较的技术,如 C#和一夫一妻制、Java 和 JOGL、C++和 Direct3D 等等,构建一个类似的游戏引擎。如果你想学习更多关于 JavaScript、HTML5 或 WebGL 的知识,请参考本章末尾“技术”部分的参考资料。
设置您的开发环境
您将要构建的游戏引擎将可以通过运行在任何操作系统(OS)上的 web 浏览器来访问。您将要设置的开发环境也是与操作系统无关的。为简单起见,以下说明基于 Windows 10 操作系统。您应该能够在基于 Unix 的环境(如 MacOS 或 Ubuntu)中复制一个类似的环境,只需稍加修改。
您的开发环境包括一个集成开发环境(IDE)和一个能够托管运行中的游戏引擎的运行时 web 浏览器。我们发现的最方便的系统是 Visual Studio Code (VS Code) IDE,使用 Google Chrome web 浏览器作为运行时环境。以下是详细情况:
-
IDE :本书所有项目都基于 VS 代码 IDE。您可以从
https://code.visualstudio.com/
下载并安装该程序。 -
运行环境:你将在谷歌 Chrome 网络浏览器中执行你的视频游戏项目。您可以从
www.google.com/chrome/browser/
下载并安装该浏览器。 -
glMatrix 数学库:这是一个实现基本数学运算的库。你可以从
http://glMatrix.net/
下载这个库。在第三章中,你将把这个库集成到你的游戏引擎中,所以更多的细节将会在那里提供。
请注意,支持 JavaScript 编程语言、HTML5 或 WebGL 没有特定的系统要求。所有这些技术都嵌入在 web 浏览器运行时环境中。
Note
如前所述,我们选择了基于 VS 代码的开发环境,因为我们发现它是最方便的。还有许多其他的选择也是免费的,包括但不限于 NetBeans、IntelliJ IDEA、Eclipse 和 Sublime。
下载和安装 JavaScript 语法检查器
我们发现 ESLint 是检测潜在 JavaScript 源代码错误的有效工具。您可以通过以下步骤将 ESLint 集成到 VS 代码中:
-
转到
https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint
,点击安装。 -
您将被提示打开 VS 代码,并且可能需要在应用中再次单击 install。
以下是使用 ESLint 的一些有用参考:
-
有关如何使用 ESLint 的说明,请参见
https://eslint.org/docs/user-guide/
。 -
有关 ESLint 如何工作的详细信息,请参见
https://eslint.org/docs/developer-guide/
。
下载和安装 LiveServer
运行游戏引擎需要 VS 代码的 LiveServer 扩展。它通过 VS 代码在你的计算机上本地启动一个 web 服务器来托管开发的游戏。与 ESLint 非常相似,您可以通过以下步骤安装 LiveServer:
-
转到
https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer
,点击安装。 -
您将被提示打开 VS 代码,并且可能需要在应用中再次单击 install。
在 VS 代码开发环境中工作
VS 代码 IDE 易于使用,本书中的项目只需要编辑器。组织在父文件夹下的相关源代码文件被 VS 代码解释为一个项目。要打开项目,请选择文件➤打开文件夹,导航并选择包含项目源代码文件的父文件夹。一旦项目打开,你需要熟悉 VS 代码的基本窗口,如图 1-1 所示。
图 1-1
VS 代码集成开发环境
-
Explorer 窗口:该窗口显示项目的源代码文件。如果您不小心关闭了此窗口,可以通过选择查看➤资源管理器来调用它。
-
编辑器窗口:该窗口显示并允许您编辑项目的源代码。通过在资源管理器窗口中单击一次相应的文件名,可以选择要使用的源代码文件。
-
输出窗口:我们的项目中没有使用这个窗口;单击窗口右上角的“x”图标,随意关闭它。
用 VS 代码创建 HTML5 项目
您现在已经准备好创建您的第一个 HTML5 项目了:
图 1-2
打开项目文件夹
- 使用文件资源管理器,在您想要保存项目的位置创建一个目录。该目录将包含与您的项目相关的所有源代码文件。在 VS 代码中,选择文件➤打开文件夹并导航到您创建的目录。
图 1-3
空的 VS 代码项目
- VS 代码将打开项目文件夹。您的 IDE 看起来应该类似于图 1-3;请注意,当项目文件夹为空时,资源管理器窗口也为空。
图 1-4
创建index.html
文件
-
您现在可以创建您的第一个 HTML 文件
index.html
。选择文件➤新文件,并将文件命名为index.html
。这将作为应用启动时的主页或登录页。 -
在编辑器窗口中,将以下文本输入您的
index.html
:
<!DOCTYPE html>
<!--
This is a comment!
-->
<html>
<head>
<title>TODO supply a title</title>
</head>
<body>
<div>TODO write content</div>
</body>
</html>
第一行声明该文件是一个 HTML 文件。在<!--
和-->
标签内的块是注释块。互补的<html></html>
标签包含了所有的 HTML 代码。在这种情况下,模板定义头部和身体部分。页眉设置网页的标题,而正文是网页所有内容的位置。
如图 1-5 所示,你可以通过点击你的 VS 代码右下角的“上线”按钮或者按 Alt+L Alt+O 来运行这个项目,有可能在你第一次进入之前的 HTML 代码之后,“上线”按钮就不会出现了。在这种情况下,只需在浏览器窗口中右键单击index.html
文件,然后单击“用实时服务器打开”菜单项即可启动网页。第一次后,IDE 的右下区域会出现“上线”按钮,如图 1-5 所示。
图 1-5
单击“上线”按钮运行项目
Note
要运行一个项目,当点击“Go Live”按钮或按下 Alt+L Alt+O 键时,必须在编辑器中打开该项目的index.html
文件。当项目中有其他 JavaScript 源代码文件时,这将在后续章节中变得很重要。
图 1-6 显示了运行默认项目时的样子。请注意,在项目开始运行后,“Go Live”按钮会更新其标签,显示“Port:5500”您可以再次单击此按钮,断开 IDE 与网页的连接,再次观察“上线”标签。再次单击该按钮将重新运行项目。
图 1-6
运行简单的 HTML5 项目
要停止程序,只需关闭网页。您已经成功运行了您的第一个 HTML5 项目。通过开发这个非常简单的项目,您已经熟悉了 IDE 环境。
Note
对于调试,我们推荐 Chrome 开发者工具。当您的项目正在运行时,可以通过在浏览器窗口中键入 Ctrl+Shift+I(或 F12 键)来访问这些工具。要了解关于这些工具的更多信息,请参考 https://developer.chrome.com/docs/devtools/
。
如何使用这本书
这本书通过构建与你刚刚经历过的项目相似的项目来指导你开发一个游戏引擎。每章涵盖了一个典型游戏引擎的基本组件,并且每章中的部分描述了构成相应组件的重要概念和实现项目。在整个文本中,每一节的项目都是建立在前面项目的成果之上的。虽然这使得在书中跳来跳去有点挑战性,但它会给你实践经验,并对不同概念之间的关系有一个坚实的理解。此外,与总是处理新的和极简的项目不同,您将获得构建更大和更有趣的项目的经验,同时将新功能集成到您不断扩展的游戏引擎中。
这些项目从演示简单的概念开始,如画一个简单的正方形,但很快发展到演示更复杂的概念,如使用用户定义的坐标系和实现像素精确的碰撞检测。最初,由于您已经有了构建第一个 HTML5 应用的经验,您将得到详细步骤和完整源代码清单的指导。随着您对开发环境和技术的熟悉,每个项目附带的指南和源代码清单将转移到重要的实现细节上。最终,随着项目复杂性的增加,讨论将只集中在重要和相关的问题上,而简单的源代码更改将不会被提及。
最终的代码库是一个完整而实用的游戏引擎,你将在本书的过程中逐步开发它;这是一个很好的*台,在这个*台上你可以开始构建你自己的 2D 游戏。这正是本书最后一章所做的,引导你从概念化到设计到实现一个休闲的 2D 游戏。
有几种方法可以让你理解这本书。最显而易见的方法是,按照书中的每一步,将代码输入到项目中。从学习的角度来看,这是吸收所呈现信息的最有效方式;然而,我们知道这可能不是最现实的,因为这种方法可能需要大量的代码或调试。或者,我们建议您在开始新的部分时运行并检查已完成项目的源代码。这样做可以让您预览当前部分的项目,让您清楚地了解最终目标,并让您看到项目试图实现的目标。当您自己构建代码时遇到问题时,您可能会发现完整的项目代码非常有用,因为在调试困难的情况下,您可以将自己的代码与完整项目的代码进行比较。
Note
我们发现 WinMerge 程序( http://winmerge.org/
)是比较源代码文件和文件夹的优秀工具。Mac 用户可以出于类似的目的使用 FileMerge 工具。
最后,在完成一个项目之后,我们建议您将您的实现的行为与所提供的已完成的实现进行比较。通过这样做,您可以观察您的代码是否如预期的那样运行。
你如何制作一个伟大的电子游戏?
虽然这本书的重点是游戏引擎的设计和实现,但理解不同的组件如何有助于创建一个有趣和引人入胜的视频游戏也很重要。从第四章开始,在每一章的结尾都包含了“游戏设计注意事项”一节,将引擎组件的功能与游戏设计的元素联系起来。本节介绍了这些讨论的框架。
这是一个复杂的问题,制作人们喜欢玩的视频游戏没有确切的公式,就像制作人们喜欢看的电影没有确切的公式一样。我们都见过看起来很棒的大预算电影,其特点是顶级的演技、编剧和导演在票房上的才华炸弹,我们也都见过大型工作室的大预算游戏无法抓住玩家的想象力。同样的道理,不知名导演的电影可以吸引全世界的注意力,不知名的小工作室的游戏可以席卷市场。
虽然制作一款优秀的游戏没有明确的指导,但许多元素和谐地共同作用,创造出的最终体验要大于其各个部分的总和,所有的游戏设计师都必须成功地解决其中的每一个问题,才能制作出值得一玩的东西。这些要素包括以下内容:
-
技术设计:这包括所有游戏代码和游戏*台,一般不会直接暴露给玩家;相反,它为游戏体验的各个方面奠定了基础。这本书主要关注与游戏技术设计相关的问题,包括特定的任务,如在屏幕上绘制元素所需的代码行,以及更多的架构考虑因素,如确定如何以及何时将资产加载到内存中的策略。技术设计问题以多种方式影响玩家体验(例如,玩家在游戏期间经历“加载”延迟的次数或者游戏每秒显示多少帧),但是技术设计通常对玩家是不可见的,因为它运行在所谓的表示层或者玩家在游戏期间遇到的所有视听和/或触觉反馈之下。
-
游戏机制:游戏机制是对给定游戏体验的基础的抽象描述。游戏机制的类型包括谜题、诸如跳跃或瞄准的灵活性挑战、定时事件、战斗遭遇等。游戏机制是一个框架;特定的谜题、遭遇和游戏互动是框架的实现。例如,一个即时战略(RTS)游戏可能包括一个资源收集机制,该机制可能被描述为“玩家需要收集特定类型的资源,并将它们组合起来以构建他们可以在战斗中使用的单位。”该机制的具体实现(玩家如何定位和提取游戏中的资源,他们如何将资源从一个地方运输到另一个地方,以及组合资源以产生单位的规则)是系统设计、关卡设计和交互模型/游戏循环(在本节稍后描述)的一个方面。
-
系统设计:向核心游戏机制提供结构化挑战的内部规则和逻辑关系被称为游戏的系统设计。使用前面的 RTS 示例,一个游戏可能需要玩家收集一定量的金属矿石,并将其与一定量的木材结合,以制作一个游戏对象;制造对象需要多少资源的具体规则和创建对象的独特过程(例如,对象只能在玩家基地的特定结构中产生,并且在玩家开始过程后需要 x 分钟数才能出现)是系统设计的方面。休闲游戏可能有基本的系统设计。例如,像 Popcore Games 的拉动别针这样的简单益智游戏是一种系统少、复杂度低的游戏,而 RTS 游戏这样的主流游戏可能有非常复杂且相互关联的系统设计,由整个设计团队来创建和*衡。游戏系统设计通常是游戏设计最隐藏的复杂性所在;当设计者在定义所有有助于实现游戏机制的变量时,很容易迷失在复杂和*衡依赖的海洋中。对玩家来说看起来相当简单的系统可能需要许多组件一起工作,并且彼此之间达到完美的*衡,低估系统的复杂性可能是新手(和老手)遇到的最大陷阱之一!)游戏设计师。在你知道你将要进入的是什么之前,总是假设你创建的系统将会比你预期的要复杂得多。
-
关卡设计:一个游戏的关卡设计反映了其他八个元素在游戏的各个“模块”中的具体组合方式,玩家必须完成某个模块的目标才能进入下一个部分(一些游戏可能只有一个关卡,而另一些则有几十个)。单个游戏中的关卡设计都可以是同一核心机制和系统设计的变体(像俄罗斯方块和宝石迷阵这样的游戏是许多关卡都专注于同一机制的游戏的例子),而其他游戏将混合和匹配机制和系统设计以实现不同关卡之间的多样性。大多数游戏都有一个主要的机制和一个跨游戏的系统设计方法,并会在不同的关卡之间添加一些小的变化来保持新鲜感(不断变化的环境、不断变化的难度、增加时间限制、增加复杂性等),尽管偶尔游戏会引入新的关卡,这些关卡依赖于完全独立的机制和系统来给玩家带来惊喜并保持他们的兴趣。游戏中的高级设计是在创建展示机械和系统设计的游戏“块”和在这些块之间进行足够的改变以保持玩家在游戏过程中的兴趣之间的*衡(但不要在块之间改变太多以至于游戏感觉脱节和脱节)。
-
交互模型:交互模型是按键、按钮、控制杆、触摸手势等的组合,用于与游戏交互以完成任务,以及支持游戏世界中这些交互的图形用户界面。一些游戏理论家将游戏的用户界面(UI)设计分成一个单独的类别(游戏 UI 包括菜单设计、物品清单、*视显示器(hud))等内容),但交互模型与 UI 设计密切相关,将这两个元素视为不可分割是一个很好的做法。在前面提到的 RTS 游戏中,交互模型包括选择游戏中的对象、移动这些对象、打开菜单和管理库存、保存进度、开始战斗和排队构建任务所需的动作。交互模型完全独立于机械和系统设计,并且只涉及玩家必须采取的物理动作来发起行为(例如,点击鼠标按钮、按键、移动操纵杆、滚轮);UI 是连接到那些动作(屏幕上的按钮、菜单、状态、音频提示、振动等)的视听或触觉反馈。
-
游戏设定:你是在外星球吗?在幻想世界里?在抽象的环境中?游戏设置是游戏体验的重要组成部分,通过与视听设计的合作,将原本互不关联的基本交互转变为引人入胜的体验。游戏设定不需要精心制作才能有效;长期受欢迎的益智游戏俄罗斯方块有一个相当简单的设置,没有真正的叙事包装,但抽象设置、视听设计和关卡设计的结合非常匹配,对玩家年复一年投入数百万小时的体验做出了重大贡献。
-
视觉设计:视频游戏在很大程度上是一种视觉媒体,所以毫不奇怪,公司经常花在游戏视觉设计上的钱和花在代码技术执行上的钱一样多,甚至更多。大型游戏是成千上万视觉资产的集合,包括环境、角色、物体、动画和电影艺术;即使是小型的休闲游戏,通常也会附带成百上千个独立的视觉元素。玩家在游戏中与之互动的每个对象都必须是一个独特的资产,如果该资产包括比将它从屏幕上的一个位置移动到另一个位置或更改比例或不透明度更复杂的动画,则该对象很可能需要由艺术家制作动画。游戏图形不需要像照片一样逼真,也不需要在风格上精心制作,以获得出色的视觉效果或有效地表现场景(许多游戏有意利用简单的视觉风格),但最好的游戏会将艺术指导和视觉风格视为玩家体验的核心,视觉选择会是有意的,并与游戏场景和机制很好地匹配。
-
音频设计:这包括音乐和音效,环境背景声音,以及所有与玩家动作(选择/使用/交换物品,打开库存,调用菜单等)相关的声音。音频设计功能与视觉设计携手传递和强化游戏设置,许多新设计师严重低估了声音让玩家沉浸在游戏世界中的影响。想象一下星球大战,例如,没有音乐,光剑音效,达斯·维德的呼吸,或者 R2D2 特有的哔哔声;音效和乐谱与视觉效果一样是体验的基础。
-
元游戏(Meta-game):元游戏的核心是个人目标如何聚集在一起,推动玩家体验游戏(通常通过得分、按顺序解锁个人关卡、通过叙事进行游戏,等等)。在许多现代游戏中,元游戏是叙事弧线或故事;玩家通常不会收到“分数”本身,而是随着他们在游戏关卡中的进展,揭示一个线性或半线性的故事,推动故事向前发展。其他游戏(尤其是社交和竞技游戏)涉及玩家“升级”他们的角色,这可能是通过游戏叙事体验进行游戏的结果,或者只是冒险进入游戏世界并接受个人挑战,从而为角色提供经验值。当然,其他游戏继续专注于得分或赢得对其他玩家的回合。
视频游戏的魔力通常来自这九个元素之间的相互作用,最成功的游戏在统一的视觉中很好地*衡了每一个元素,以确保和谐的体验;这种*衡对于每个人的努力来说都是独一无二的,在从任天堂的动物穿越到摇滚明星的红色死亡救赎 2 的游戏中都可以找到。许多成功游戏的核心游戏机制通常是一个或多个相当简单、常见的主题的变体(例如,拉针,是一个完全基于从容器中拉出虚拟针来释放彩色球的游戏),但视觉设计、叙事背景、音频效果、交互和进度系统与游戏机制一起工作,创造了一种独特的体验,这种体验比其各个部分的总和更有吸引力,让玩家想一次又一次地回到它。伟大的游戏从简单到复杂都有,但它们都以支持设计元素的优雅*衡为特色。
参考
本书中的示例是在假设您理解数据封装、继承和基本数据结构(如链表和字典)的基础上创建的,并且熟悉代数和几何的基础知识,尤其是线性方程和坐标系。本书中的许多例子应用并实现了计算机图形学和线性代数中的概念。这些概念需要更深入的研究。感兴趣的读者可以在其他书中了解更多关于这些主题的内容。
-
计算机图形:
-
马斯纳和雪莉。计算机图形学基础,第 4 版。CRC 出版社,2016。
-
安格尔和施赖纳。交互式计算机图形:使用 WebGL 的自顶向下方法,第 7 版。培生教育,2014 年。
-
-
线性代数:
-
宋和史密斯。【Unity 3D 游戏开发的基础数学:数学基础初学者指南。Apress,2019。
-
约翰逊,里斯和阿诺德。线性代数入门,第 5 版。艾迪森-韦斯利,2002 年。
-
安东和罗里斯。初等线性代数:应用版,第 11 版。威利,2013。
-
技术
以下列表提供了获取本书中使用的技术的其他信息的链接:
-
JavaScript :
www.w3schools.com/js
-
html 5:
www.w3schools.com/html/html5_intro.asp
-
WebGL :
www.khronos.org/webgl
-
OpenGL :
www.opengl.org
-
Visual Studio 代码 :
https://code.visualstudio.com/
-
铬 :
www.google.com/chrome
-
GL matrix:
http://glMatrix.net
-
ESLint :
www.eslint.org
二、使用 HTML5 和 WebGL
完成本章后,您将能够
-
为您的简单游戏引擎创建一个新的 JavaScript 源代码文件
-
用 WebGL 画一个简单的恒色正方形
-
定义 JavaScript 模块和类来封装和实现核心游戏引擎功能
-
理解抽象和组织源代码结构对支持复杂性增长的重要性
介绍
绘画是所有视频游戏最基本的功能之一。一个游戏引擎应该为它的绘图系统提供一个灵活且对程序员友好的界面。这样,在构建游戏时,设计者和开发者可以专注于游戏本身的重要方面,如机械、逻辑和美学。
WebGL 是一个现代的 JavaScript 图形应用编程接口(API ),专为基于 web 浏览器的应用设计,通过直接访问图形硬件来提高质量和效率。由于这些原因,WebGL 作为一个极好的基础来支持游戏引擎中的绘图,特别是对于那些被设计成在互联网上玩的视频游戏。
本章研究了使用 WebGL 绘图的基础,设计了封装无关细节的抽象以方便编程,并构建了组织复杂源代码系统的基础设施以支持未来的扩展。
Note
您将在本书中开发的游戏引擎基于最新版本的 WebGL 规范:2.0 版。为了简洁起见,术语 WebGL 将用于指代这个 API。
绘画用画布
要进行绘制,您必须首先在网页中定义并指定一个区域。通过使用 HTML canvas
元素为 WebGL 绘图定义一个区域,可以很容易地实现这一点。canvas
元素是一个绘图容器,可以用 JavaScript 访问和操作。
HTML5 画布项目
这个项目演示了如何在网页上创建和清除一个canvas
元素。图 2-1 显示了一个运行该项目的例子,该项目在chapter2/2.1.html5_canvas
文件夹中定义。
图 2-1
运行 HTML5 画布项目
该项目的目标如下:
-
学习如何设置 HTML
canvas
元素 -
学习如何从 HTML 文档中检索用于 JavaScript 的
canvas
元素 -
学习如何从检索到的
canvas
元素创建 WebGL 的引用上下文,并通过 WebGL 上下文操纵画布
创建和清除 HTML 画布
在第一个项目中,您将创建一个空的 HTML5 画布,并使用 WebGL:
- 创建一个新项目,在你选择的目录下创建一个名为
html5_canvas
的新文件夹,复制并粘贴你在第一章的前一个项目中创建的index.html
文件。
Note
从这一点开始,当要求您创建一个新项目时,您应该遵循前面描述的过程。也就是说,用项目的名称创建一个新文件夹,并复制/粘贴以前项目的文件。这样,您的新项目可以在旧项目的基础上进行扩展,同时保留原有的功能。
图 2-2
编辑项目中的index.html
文件
-
在编辑器中打开
html5_canvas
文件夹,根据需要将其展开,点击index.html
文件,打开index.html
文件,如图 2-2 所示。 -
通过在
body
元素内的index.html
文件中添加以下行来创建用于绘图的 HTMLcanvas
:
<canvas id="GLCanvas" width="640" height="480">
Your browser does not support the HTML5 canvas.
</canvas>
代码用指定的width
和height
属性定义了一个名为GLCanvas
的canvas
元素。正如您稍后将体验到的,您将检索对GLCanvas
的引用以绘制到该区域中。如果您的浏览器不支持使用WebGL
绘图,将显示元素内的文本。
Note
标签<body>
和</body>
之间的线被称为“在body
元素内”对于本书的其余部分,“在AnyTag
元素内”将用于指代元素的开始(<AnyTag>
)和结束(</AnyTag>
)之间的任何行。
- 创建一个包含 JavaScript 编程代码的
script
元素,同样在body
元素中:
<script type="text/javascript">
// JavaScript code goes here.
</script>
这负责这个项目的 HTML 部分。现在,您将为示例的剩余部分编写 JavaScript 代码:
- 通过在
script
元素中添加以下代码行,在 JavaScript 代码中检索对GLCanvas
的引用:
"use strict";
let canvas = document.getElementById("GLCanvas");
Note
JavaScript 关键字定义了变量。
第一行“use strict”是一个 JavaScript 指令,表示代码应该在“严格模式”下执行,其中使用未声明的变量是一个运行时错误。第二行创建一个名为canvas
的新变量,并将该变量引用到GLCanvas
绘图区域。
Note
所有局部变量名称都以小写字母开头,如canvas
。
- 通过添加以下代码,检索对 WebGL 上下文的引用并将其绑定到绘图区域:
let gl = canvas.getContext("webgl2") ||
canvas.getContext("experimental-webgl2");
如代码所示,检索到的 WebGL 版本 2 上下文的引用存储在名为gl
的局部变量中。通过这个变量,您可以访问 WebGL 2.0 的所有功能。同样,在本书的其余部分,术语 WebGL 将用于指代 web GL 2.0 版 API。
- 通过添加以下内容,通过 WebGL 将画布绘图区域清除为您喜欢的颜色:
if (gl !== null) {
gl.clearColor(0.0, 0.8, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
}
此代码检查以确保正确检索 WebGL 上下文,设置清除颜色,并清除绘图区域。请注意,清除颜色以 RGBA 格式给出,浮点值范围从 0.0 到 1.0。RGBA 格式中的第四个数字是 alpha 通道。在后面的章节中你会学到更多关于阿尔法通道的知识。目前,始终将 1.0 指定给 alpha 通道。指定的颜色(0.0, 0.8, 0.0, 1.0)
,红色和蓝色通道的值为零,绿色通道的强度为 0.8,即 80%。因此,画布区域被清除为浅绿色。
- 通过插入以下代码行,向
document
添加一个简单的write
命令来识别canvas
:
document.write("<br><b>The above is WebGL draw area!</b>");
可以参考chapter2/2.1.html5_canvas
项目中的index.html
文件中的最终源代码。运行这个项目,你应该在你的浏览器窗口上看到一个浅绿色的区域,如图 2-1 所示。这是您定义的 640×480 的画布绘制区域。
您可以通过将gl.clearColor()
的 RGBA 设置为 1 来尝试将清除的颜色更改为白色,或者通过将颜色设置为 0 并保留 alpha 值为 1 来尝试将清除的颜色更改为黑色。请注意,如果将 alpha 通道设置为 0,画布颜色将会消失。这是因为 alpha 通道中的 0 值表示完全透明,因此,您将“看穿”画布并观察网页的背景颜色。您也可以尝试通过将 640×480 值更改为您喜欢的任何数字来改变画布的分辨率。请注意,这两个数字指的是像素数,因此必须始终是整数。
分离 HTML 和 JavaScript
在前一个项目中,您创建了一个 HTML canvas
元素,并使用 WebGL 清除了画布定义的区域。注意,所有的功能都聚集在index.html
文件中。随着项目复杂性的增加,这种功能的聚集会很快变得难以管理,并对系统的可编程性产生负面影响。由于这个原因,在本书的整个开发过程中,引入一个概念后,将努力把相关的源代码分成定义良好的源代码文件或面向对象编程风格的类。为了开始这个过程,来自前一个项目的 HTML 和 JavaScript 源代码将被分离到不同的源代码文件中。
JavaScript 源文件项目
这个项目演示了如何在逻辑上将源代码分成适当的文件。您可以通过创建一个名为core.js
的单独的 JavaScript 源代码文件来实现这一点,该文件实现了index.html
文件中的相应功能。网页将按照index.html
文件中代码的指示加载 JavaScript 源代码。如图 2-3 所示,该项目运行时看起来与上一个项目相同。这个项目的源代码位于chapter2/2.2.javascript_source_file
文件夹中。
图 2-3
运行 JavaScript 源文件项目
该项目的目标如下:
-
了解如何将源代码分成不同的文件
-
以逻辑结构组织代码
单独的 JavaScript 源代码文件
本节详细介绍了如何创建和编辑新的 JavaScript 源代码文件。您应该熟悉这个过程,因为您将在本书中创建大量的源代码文件。
图 2-4
创建新的源代码文件夹
-
创建一个名为
javascript_source_file
的新 HTML5 项目。回想一下,一个新项目是通过创建一个具有适当名称的文件夹,从以前的项目中复制文件,并编辑index.html
的<Title>
元素来反映新项目而创建的。 -
鼠标悬停在项目文件夹上,点击新建文件夹图标,在项目文件夹内新建一个名为
src
的文件夹,如图 2-4 所示。该文件夹将包含您的所有源代码。
图 2-5
添加新的 JavaScript 源代码文件
- 右键单击
src
文件夹,在src
文件夹下新建一个源代码文件,如图 2-5 所示。将新的源文件命名为core.js
。
Note
在 VS 代码中,您可以使用资源管理器窗口中的右键菜单来创建/复制/重命名文件夹和文件。
-
打开新的
core.js
源文件进行编辑。 -
定义引用 WebGL 上下文的变量,并添加允许您访问该变量的函数:
"use strict";
let mGL = null;
function getGL() { return mGL; }
Note
在整个文件或模块中可访问的变量的名字以小写字母“m”开头,如mGL
。
- 定义
initWebGL()
函数,通过将适当的画布id
作为参数传入来检索GLCanvas
,将绘图区域绑定到 WebGL 上下文,将结果存储在定义的mGL
变量中,并清除绘图区域:
function initWebGL(htmlCanvasID) {
let canvas = document.getElementById(htmlCanvasID);
mGL = canvas.getContext("webgl2") ||
canvas.getContext("experimental-webgl2");
if (mGL === null) {
document.write("<br><b>WebGL 2 is not supported!</b>");
return;
}
mGL.clearColor(0.0, 0.8, 0.0, 1.0);
}
注意,这个函数类似于您在前一个项目中键入的 JavaScript 源代码。这是因为在这种情况下,您所做的一切都是不同的,将 JavaScript 源代码与 HTML 代码分开。
Note
所有函数名都以小写字母开头,如initWebGL()
。
-
定义
clearCanvas()
函数调用 WebGL 上下文来清空画布绘图区域: -
定义一个函数,在 web 浏览器加载完
index.html
文件后,对画布区域进行初始化和清除;
function clearCanvas() {
mGL.clear(mGL.COLOR_BUFFER_BIT);
}
window.onload = function() {
initWebGL("GLCanvas");
clearCanvas();
}
从 index.html 加载并运行 JavaScript 源代码
有了在core.js
文件中定义的所有 JavaScript 功能,您现在需要通过index.html
文件加载这个文件来操作您的 web 页面:
-
打开
index.html
文件进行编辑。 -
像前面的项目一样,创建 HTML 画布
GLCanvas
。 -
通过在
head
元素中包含以下代码来加载core.js
源代码:
<script type="module" src="./src/core.js"></script>
使用这段代码,core.js
文件将作为index.html
定义的网页的一部分被加载。回想一下,您已经为window.onload
定义了一个函数,当index.html
的加载完成时,该函数将被调用。
可以参考chapter2/2.2.javascript_source_file
项目文件夹下的core.js
和index.html
文件中的最终源代码。虽然这个项目的输出与上一个项目的输出相同,但是您的代码组织将允许您在继续添加新功能时扩展、调试和理解游戏引擎。
Note
回想一下,要运行一个项目,单击 VS 代码窗口右下角的“Go Live”按钮,或者键入 Alt+L Alt+O 键,同时在编辑器窗口中打开相关的index.html
文件。在这种情况下,当core.js
文件在编辑器窗口中打开时,如果您单击“Go Live”按钮,项目将不会运行。
观察
仔细检查您的index.html
文件,并将其内容与之前项目中的相同文件进行比较。您会注意到,前一个项目中的index.html
文件包含两种类型的信息(HTML 和 JavaScript 代码),这个项目中的同一个文件只包含前者,所有的 JavaScript 代码都被提取到core.js
。这种清晰的信息分离便于理解源代码,并提高了对更复杂系统的支持。从现在开始,所有的 JavaScript 源代码都将被添加到单独的源代码文件中。
使用 WebGL 的基本绘图
一般来说,绘图包括几何数据和处理数据的指令。在 WebGL 的情况下,用于处理数据的指令在 OpenGL 着色语言(GLSL)中指定,并且被称为着色器。为了使用 WebGL 绘图,程序员必须在 CPU 中定义几何数据和 GLSL 着色器,并将其加载到绘图硬件或图形处理单元(GPU)中。这个过程涉及到大量的 WebGL 函数调用。本节详细介绍了 WebGL 绘制步骤。
重要的是集中精力学习这些基本步骤,避免被不太重要的 WebGL 配置细微差别分散注意力,以便您可以继续学习构建游戏引擎时涉及的整体概念。
在下面的项目中,您将通过关注最基本的操作来学习使用 WebGL 绘图。这包括将简单的正方形几何图形从 CPU 加载到 GPU,创建恒定颜色着色器,以及绘制带有两个三角形的简单正方形的基本说明。
绘制一个正方形项目
这个项目引导你完成在画布上画一个正方形所需的步骤。图 2-6 显示了一个运行这个项目的例子,它被定义在chapter2/2.3.draw_one_square
文件夹中。
图 2-6
运行绘制一个正方形项目
该项目的目标如下:
-
了解如何将几何数据加载到 GPU
-
了解用于使用 WebGL 绘图的简单 GLSL 着色器
-
了解如何编译着色器并将其加载到 GPU
-
了解使用 WebGL 绘图所需的步骤
-
演示基于简单源代码文件的类似单例的 JavaScript 模块的实现
设置并加载原始几何数据
为了使用 WebGL 高效地绘制,与要绘制的几何图形相关联的数据(如正方形的顶点位置)应该存储在 GPU 硬件中。在下面的步骤中,您将在 GPU 中创建一个连续的缓冲区,将单位正方形的顶点位置加载到缓冲区中,并将对 GPU 缓冲区的引用存储在一个变量中。借鉴前面的项目,相应的 JavaScript 代码将存储在一个新的源代码文件中,vertex_buffer.js
。
Note
单位正方形是以原点为中心的 1×1 正方形。
-
在
src
文件夹中创建一个新的 JavaScript 源文件,并将其命名为vertex_buffer.js
。 -
使用 JavaScript
import
语句将所有从core.js
文件导出的功能作为core
导入:
"use strict";
import * as core from "./core.js";
Note
有了 JavaScript import
和即将出现的export
,文件中定义的语句、特性和功能可以被方便地封装和访问。在这种情况下,从core.js
导出的功能被导入到vertex_buffer.js
中,并可通过模块标识符core
访问。例如,正如您将看到的,在这个项目中,core.js
定义并导出了一个getGL()
函数。使用给定的import
语句,可以在vertex_buffer.js
文件中以core.getGL()
的形式访问该函数。
-
声明变量
mGLVertexBuffer
来存储对 WebGL 缓冲区位置的引用。记得定义一个函数来访问这个变量。 -
定义变量
mVerticesOfSquare
并用单位正方形的顶点初始化它:
let mGLVertexBuffer = null;
function get() { return mGLVertexBuffer; }
let mVerticesOfSquare = [
0.5, 0.5, 0.0,
-0.5, 0.5, 0.0,
0.5, -0.5, 0.0,
-0.5, -0.5, 0.0
];
在所示的代码中,每行三个数字是顶点的 x、y 和 z 坐标位置。请注意,z 维度设置为 0.0,因为您正在构建一个 2D 游戏引擎。还要注意,这里使用了 0.5,所以我们在 2D 空间中定义了一个正方形,它的边长等于 1,并且以原点或单位正方形为中心。
- 定义
init()
函数,通过gl
上下文在 GPU 中分配一个缓冲区,并将顶点加载到 GPU 中分配的缓冲区:
function init() {
let gl = core.getGL();
// Step A: Create a buffer on the gl context for our vertex positions
mGLVertexBuffer = gl.createBuffer();
// Step B: Activate vertexBuffer
gl.bindBuffer(gl.ARRAY_BUFFER, mGLVertexBuffer);
// Step C: Loads mVerticesOfSquare into the vertexBuffer
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array(mVerticesOfSquare), gl.STATIC_DRAW);
}
这段代码首先通过core.getGL()
函数访问 WebGL 绘图上下文。之后,步骤 A 在 GPU 上创建一个缓冲区,用于存储正方形的顶点位置,并将对 GPU 缓冲区的引用存储在变量mGLVertexBuffer
中。步骤 B 激活新创建的缓冲区,步骤 C 将正方形的顶点位置加载到 GPU 上激活的缓冲区中。关键字STATIC_DRAW
通知绘图硬件这个缓冲区的内容不会被改变。
Tip
记住通过getGL()
函数访问的mGL
变量是在core.js
文件中定义的,并由initWebGL()
函数初始化。您将在core.js
文件中定义一个export
语句,以便在接下来的步骤中提供对该函数的访问。
- 通过使用以下代码导出
init()
和get()
函数,为引擎的其余部分提供对它们的访问:
export {init, get}
定义了加载顶点位置的功能后,现在就可以定义和加载 GLSL 着色器了。
设置 GLSL 着色器
术语着色器指的是运行在 GPU 上的程序或指令集合。在游戏引擎的上下文中,着色器必须总是成对定义,由顶点着色器和相应的片段着色器组成。GPU 将对每个图元顶点执行一次顶点着色器,对图元覆盖的每个像素执行一次片段着色器。例如,您可以定义一个具有四个顶点的正方形,并显示该正方形以覆盖 100×100 像素的区域。为了绘制这个正方形,WebGL 将调用顶点着色器 4 次(每个顶点一次),并执行片段着色器 10,000 次(每个 100×100 像素一次)!
在 WebGL 的情况下,顶点和片段着色器都是用 OpenGL 着色语言(GLSL)实现的。GLSL 是一种语法类似于 C 编程语言的语言,专门为处理和显示图形元素而设计。你将学到足够的 GLSL 来支持游戏引擎的绘图。
在以下步骤中,您将把顶点着色器和片段着色器的源代码加载到 GPU 内存中,编译并链接到单个着色器程序中,并将链接的程序加载到 GPU 内存中进行绘制。在这个项目中,着色器源代码在index.html
文件中定义,而着色器的加载、编译和链接在shader_support.js
源文件中定义。
Note
WebGL 上下文可以被视为 GPU 硬件的抽象。为了提高可读性,WebGL 和 GPU 这两个术语有时可以互换使用。
定义顶点和片段着色器
GLSL 着色器是由 GLSL 指令组成的简单程序:
- 通过打开
index.html
文件定义顶点着色器,并在head
元素中添加以下代码:
<script type="x-shader/x-vertex" id="VertexShader">
// this is the vertex shader
attribute vec3 aVertexPosition; // Expects one vertex position
// naming convention, attributes always begin with "a"
void main(void) {
// Convert the vec3 into vec4 for scan conversion and
// assign to gl_Position to pass vertex to the fragment shader
gl_Position = vec4(aVertexPosition, 1.0);
}
// End of vertex shader
</script>
Note
着色器属性变量的名称以小写字母“a”开头,如aVertexPosition
。
script
元素类型被设置为x-shader/x-vertex
,因为这是着色器的通用约定。正如你将看到的,值为VertexShader
的id
字段允许你识别并加载这个顶点着色器到内存中。
GLSL attribute
关键字标识将被传递到 GPU 中的顶点着色器的逐顶点数据。在这种情况下,aVertexPosition
属性的数据类型是vec3
或者三个浮点数的数组。正如您将在后面的步骤中看到的,aVertexPosition
将被设置为引用单位正方形的顶点位置。
gl_Position
是一个 GLSL 内置变量,特别是一个包含顶点位置的四个浮点数的数组。在这种情况下,数组的第四个位置将始终是 1.0。代码显示着色器将aVertexPosition
转换为vec4
,并将信息传递给 WebGL。
- 通过在
head
元素中添加以下代码,在index.html
中定义片段着色器:
<script type="x-shader/x-fragment" id="FragmentShader">
// this is the fragment (or pixel) shader
void main(void) {
// for every pixel called (within the square) sets
// constant color white with alpha-channel value of 1.0
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
// End of fragment/pixel shader
</script>
注意不同的type
和id
字段。回想一下,每个像素调用一次片段着色器。变量gl_FragColor
是决定像素颜色的内置变量。在这种情况下,返回颜色(1,1,1,1)
,即白色。这意味着所有被覆盖的像素将被阴影化为恒定的白色。
有了在index.html
文件中定义的顶点和片段着色器,您现在就可以实现编译、链接和加载结果着色器程序到 GPU 的功能了。
编译、链接和加载顶点和片段着色器
为了在逻辑分离的源文件中维护源代码,您将在新的源代码文件shader_support.js
中创建着色器支持功能。
-
创建一个新的 JavaScript 文件
shader_support.js
。 -
从
core.js
和vertex_buffer.js
文件导入功能: -
定义两个变量
mCompiledShader
和mVertexPositionRef
,用于引用着色器程序和 GPU 中的顶点位置属性:
"use strict"; // Variables must be declared before used!
import * as core from "./core.js"; // access as core module
import * as vertexBuffer from "./vertex_buffer.js"; //vertexBuffer module
- 创建一个函数来加载和编译您在
index.html
中定义的着色器:
let mCompiledShader = null;
let mVertexPositionRef = null;
function loadAndCompileShader(id, shaderType) {
let shaderSource = null, compiledShader = null;
// Step A: Get the shader source from index.html
let shaderText = document.getElementById(id);
shaderSource = shaderText.firstChild.textContent;
let gl = core.getGL();
// Step B: Create shader based on type: vertex or fragment
compiledShader = gl.createShader(shaderType);
// Step C: Compile the created shader
gl.shaderSource(compiledShader, shaderSource);
gl.compileShader(compiledShader);
// Step D: check for errors and return results (null if error)
// The log info is how shader compilation errors are displayed.
// This is useful for debugging the shaders.
if (!gl.getShaderParameter(compiledShader, gl.COMPILE_STATUS)) {
throw new Error("A shader compiling error occurred: " +
gl.getShaderInfoLog(compiledShader));
}
return compiledShader;
}
代码的步骤 A 使用您在定义着色器时指定的id
字段在index.html
文件中查找着色器源代码,该字段可以是VertexShader
或FragmentShader
。步骤 B 在 GPU 中创建指定的着色器(顶点或片段)。步骤 C 指定源代码并编译着色器。最后,步骤 D 检查并返回对已编译着色器的引用,如果着色器编译不成功,则抛出错误。
- 现在,您可以通过定义
init()
函数来创建、编译和链接着色器程序了:
function init(vertexShaderID, fragmentShaderID) {
let gl = core.getGL();
// Step A: load and compile vertex and fragment shaders
let vertexShader = loadAndCompileShader(vertexShaderID,
gl.VERTEX_SHADER);
let fragmentShader = loadAndCompileShader(fragmentShaderID,
gl.FRAGMENT_SHADER);
// Step B: Create and link the shaders into a program.
mCompiledShader = gl.createProgram();
gl.attachShader(mCompiledShader, vertexShader);
gl.attachShader(mCompiledShader, fragmentShader);
gl.linkProgram(mCompiledShader);
// Step C: check for error
if (!gl.getProgramParameter(mCompiledShader, gl.LINK_STATUS)) {
throw new Error("Error linking shader");
return null;
}
// Step D: Gets reference to aVertexPosition attribute in the shader
mVertexPositionRef = gl.getAttribLocation(mCompiledShader,
"aVertexPosition");
}
步骤 A 通过调用带有相应参数的loadAndCompileShader()
函数,加载并编译您在index.html
中定义的着色器代码。步骤 B 附加已编译的着色器,并将两个着色器链接到一个程序中。对该程序的引用存储在变量mCompiledShader
中。在步骤 C 中的错误检查之后,步骤 D 定位并存储对顶点着色器中定义的aVertexPosition
属性的引用。
- 定义一个允许激活着色器的函数,以便它可以用于绘制正方形:
function activate() {
// Step A: access to the webgl context
let gl = core.getGL();
// Step B: identify the compiled shader to use
gl.useProgram(mCompiledShader);
// Step C: bind vertex buffer to attribute defined in vertex shader
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer.get());
gl.vertexAttribPointer(this.mVertexPositionRef,
3, // each element is a 3-float (x,y.z)
gl.FLOAT, // data type is FLOAT
false, // if the content is normalized vectors
0, // number of bytes to skip in between elements
0); // offsets to the first element
gl.enableVertexAttribArray(this.mVertexPositionRef);
}
在所示的代码中,步骤 A 通过core
模块将gl
变量设置为 WebGL 上下文。步骤 B 将编译后的着色器程序加载到 GPU 内存中,而步骤 C 将在vertex_buffer.js
中创建的顶点缓冲区绑定到顶点着色器中定义的aVertexPosition
属性。gl.vertexAttribPointer()
函数捕捉到顶点缓冲区装载了一个单位正方形的顶点,每个顶点位置包含三个浮点值。
- 最后,通过使用
export
语句导出init()
和activate()
函数,为游戏引擎的其余部分提供对它们的访问:
export { init, activate }
Note
注意,loadAndCompileShader()
函数被排除在export
语句之外。其他地方不需要这个函数,因此,遵循隐藏本地实现细节的良好开发实践,这个函数应该对这个文件保持私有。
现在已经定义了着色器加载和编译功能。现在,您可以利用并激活这些函数来使用 WebGL 绘图。
使用 WebGL 设置绘图
定义了顶点数据和着色器功能后,现在可以执行以下步骤来使用 WebGL 进行绘制。回想一下之前的项目,初始化和绘图代码是在core.js
文件中定义的。现在打开这个文件进行编辑。
-
从
vertex_buffer.js
和shader_support.js
文件导入定义的功能: -
修改
initWebGL()
函数以包含顶点缓冲区和着色器程序的初始化:
import * as vertexBuffer from "./vertex_buffer.js";
import * as simpleShader from "./shader_support.js";
function initWebGL(htmlCanvasID) {
let canvas = document.getElementById(htmlCanvasID);
// Get standard or experimental webgl and bind to the Canvas area
// store the results to the instance variable mGL
mGL = canvas.getContext("webgl2") ||
canvas.getContext("experimental-webgl2");
if (mGL === null) {
document.write("<br><b>WebGL 2 is not supported!</b>");
return;
}
mGL.clearColor(0.0, 0.8, 0.0, 1.0); // set the color to be cleared
// 1\. initialize buffer with vertex positions for the unit square
vertexBuffer.init(); // function defined in the vertex_buffer.js
// 2\. now load and compile the vertex and fragment shaders
simpleShader.init("VertexShader", "FragmentShader");
// the two shaders are defined in the index.html file
// init() function is defined in shader_support.js file
}
如代码所示,成功获取对 WebGL 上下文的引用并设置清除颜色后,首先要调用vertex_buffer.js
中定义的init()
函数,用单位正方形顶点初始化 GPU 顶点缓冲区,然后调用shader_support.js
中定义的init()
函数,加载并编译顶点和片段着色器。
- 添加一个
drawSquare()
函数,用于绘制定义好的正方形:
function drawSquare() {
// Step A: Activate the shader
simpleShader.activate();
// Step B. draw with the above settings
mGL.drawArrays(mGL.TRIANGLE_STRIP, 0, 4);
}
这段代码展示了用 WebGL 绘图的步骤。步骤 A 激活着色器程序来使用。步骤 B 发出 WebGL draw 命令。在这种情况下,您发出一个命令,将四个顶点绘制为两个相连的三角形,形成一个正方形。
-
现在你只需要修改
window.onload
函数来调用新定义的drawSquare()
函数: -
最后,通过导出
getGL()
函数,向引擎的其余部分提供对 WebGL 上下文的访问。记住,这个函数是导入的,并且已经被调用来访问vertex_buffer.js
和simple_shader.js
中的 WebGL 上下文。
window.onload = function() {
initWebGL("GLCanvas"); // Binds mGL context to WebGL functionality
clearCanvas(); // Clears the GL area
drawSquare(); // Draws one square
}
export {getGL}
回想一下,绑定到window.onload
的函数将在 web 浏览器加载了indexl.html
之后被调用。出于这个原因,WebGL 将被初始化,画布被清除为浅绿色,并将绘制一个白色正方形。您可以参考chapter2/2.3.draw_one_square
项目中的源代码来了解所描述的整个系统。
观察
运行该项目,您将在绿色画布上看到一个白色矩形。广场怎么了?请记住,1×1 正方形的顶点位置是在位置(0.5,0.5)处定义的。现在观察项目输出:白色矩形位于绿色画布的中间,正好覆盖了画布宽度和高度的一半。事实证明,WebGL 将 1.0 范围内的顶点绘制到整个定义的绘图区域上。在这种情况下,x 维度中的 1.0 映射到 640 像素,而 y 维度中的 1.0 映射到 480 像素(创建的画布维度为 640×480)。1x1 正方形被绘制到 640x480 的区域上,或者长宽比为 4:3 的区域上。由于正方形的 1:1 纵横比与显示区域的 4:3 纵横比不匹配,因此正方形显示为 4:3 矩形。这个问题将在下一章中解决。
您可以尝试在index.html
中编辑片段着色器,通过改变gl_FragColor
函数中的颜色设置来改变白色方块的颜色。请注意,alpha 通道中小于 1 的值不会导致白色正方形变得透明。绘制图元的透明度将在后面的章节中讨论。
最后,注意这个项目定义了三个独立的文件,并用 JavaScript 导入/导出语句隐藏了信息。用相应的导入和导出语句在这些文件中定义的功能被称为 JavaScript 模块。一个模块可以被认为是一个全局的单例对象,并且非常适合隐藏实现细节。shader_support
模块中的loadAndCompileShader()
函数是这个概念的一个很好的例子。然而,模块不太适合支持抽象和专门化。在接下来的部分中,您将开始使用 JavaScript 类来进一步封装这个示例的各个部分,从而形成游戏引擎框架的基础。
JavaScript 类的抽象
前一个项目将正方形的绘制分解成逻辑模块,并将这些模块实现为包含全局函数的文件。在软件工程中,这个过程被称为功能分解,实现被称为过程化编程。过程化编程通常会产生结构良好且易于理解的解决方案。这就是为什么功能分解和过程化编程经常被用于原型概念或学习新技术。
该项目通过面向对象的分析和编程来引入数据抽象,从而增强了 Draw One Square 项目。随着额外概念的引入和游戏引擎复杂性的增长,适当的数据抽象通过继承支持简单的设计、行为专门化和代码重用。
JavaScript 对象项目
这个项目演示了如何将“画一个正方形”项目中的全局函数抽象成 JavaScript 类和对象。这种面向对象的抽象将产生一个为后续项目提供可管理性和可扩展性的框架。如图 2-7 所示,当运行时,该项目在绿色画布中显示一个白色矩形,与“绘制一个正方形”项目中的矩形相同。这个项目的源代码可以在chapter2/2.4.javascript_objects
文件夹中找到。
图 2-7
运行 JavaScript 对象项目
该项目的目标如下:
-
为了将游戏引擎的代码与游戏逻辑的代码分开
-
理解如何用 JavaScript 类和对象构建抽象
创建该项目的步骤如下:
-
创建单独的文件夹来组织游戏引擎的源代码和游戏的逻辑。
-
定义一个 JavaScript 类来抽象
simple_shader
并使用这个类的一个实例。 -
定义一个 JavaScript 类来实现一个正方形的绘制,这是目前简单游戏的逻辑。
源代码组织
通过新建一个文件夹,添加一个名为src
的源代码文件夹,用 VS 代码新建一个 HTML5 项目。在src
内,创建engine
和my_game
为子文件夹,如图 2-8 所示。
图 2-8
在src
文件夹下创建engine
和my_game
src/engine
文件夹将包含游戏引擎的所有源代码,而src/my_game
文件夹将包含游戏逻辑的源代码。勤奋地组织源代码是很重要的,因为随着更多概念的引入,系统的复杂性和文件的数量会迅速增加。组织良好的源代码结构有助于理解和扩展。
Tip
my_game
文件夹中的源代码依靠engine
文件夹中定义的游戏引擎提供的功能来实现游戏。正因如此,在本书中,my_game
文件夹中的源代码通常被称为游戏引擎的客户端。
抽象游戏引擎
一个完整的游戏引擎将包括许多独立的子系统来完成不同的职责。例如,您可能熟悉或听说过用于管理要绘制的几何图形的几何子系统、用于管理图像和音频剪辑的资源管理子系统、用于管理对象交互的物理子系统等等。在大多数情况下,游戏引擎会包含每个子系统的一个唯一实例,即几何子系统、资源管理子系统、物理子系统等等的一个实例。
这些子系统将在本书后面的章节中介绍。这一节重点关注基于您在之前的项目中使用的 JavaScript 模块,建立实现这种单实例或类似单例的功能的机制和组织。
Note
所有模块和实例变量名都以“m”开头,后跟一个大写字母,如mVariable
所示。尽管 JavaScript 没有强制要求,但是您永远不应该从模块/类外部访问模块或实例变量。比如,千万不要直接访问core.mGL
;相反,调用core.getGL()
函数来访问变量。
着色器类
尽管前一个项目中的shader_support.js
文件中的代码正确地实现了所需的功能,但是变量和函数并不适合行为专门化和代码重用。例如,在需要不同类型的着色器的情况下,在实现行为和代码重用的同时修改实现可能具有挑战性。本节遵循面向对象的设计原则,并定义了一个SimpleShader
类来抽象行为并隐藏着色器的内部表示。除了创建SimpleShader
对象的多个实例的能力之外,基本功能基本上保持不变。
Note
模块标识符以小写字母开头,例如core
或vertexBuffer
。类名以大写字母开头,例如,SimpleShader
或MyGame
。
-
在
src/engine
文件夹中创建一个新的源文件,并将该文件命名为simple_shader.js
以实现SimpleShader
类。 -
导入
core
和vertex_buffer
模块: -
将
SimpleShader
声明为一个 JavaScript 类:
import * as core from "./core.js";
import * as vertexBuffer from "./vertex_buffer.js";
- 在
SimpleShader
类中定义constructor
,以加载、编译和链接顶点和片段着色器到程序中,并创建对顶点着色器中aVertexPosition
属性的引用,用于从 WebGL 顶点缓冲区加载正方形顶点位置以进行绘制:
class SimpleShader {
... implementation to follow ...
}
constructor(vertexShaderID, fragmentShaderID) {
// instance variables
// Convention: all instance variables: mVariables
this.mCompiledShader = null; // ref to compiled shader in webgl
this.mVertexPositionRef = null; // ref to VertexPosition in shader
let gl = core.getGL();
// Step A: load and compile vertex and fragment shaders
this.mVertexShader = loadAndCompileShader(vertexShaderID,
gl.VERTEX_SHADER);
this.mFragmentShader = loadAndCompileShader(fragmentShaderID,
gl.FRAGMENT_SHADER);
// Step B: Create and link the shaders into a program.
this.mCompiledShader = gl.createProgram();
gl.attachShader(this.mCompiledShader, this.mVertexShader);
gl.attachShader(this.mCompiledShader, this.mFragmentShader);
gl.linkProgram(this.mCompiledShader);
// Step C: check for error
if (!gl.getProgramParameter(this.mCompiledShader, gl.LINK_STATUS)) {
throw new Error("Error linking shader");
return null;
}
// Step D: reference to aVertexPosition attribute in the shaders
this.mVertexPositionRef = gl.getAttribLocation(
this.mCompiledShader, "aVertexPosition");
}
注意,这个构造函数本质上与上一个项目中的shader_support.js
模块中的init()
函数相同。
Note
JavaScript constructor
关键字定义了一个类的构造函数。
-
向
SimpleShader
类添加一个方法,以activate
着色器进行绘制。再一次,类似于你之前项目中shader_support.js
的activate()
功能。 -
通过在
SimpleShader
类之外创建一个函数来执行实际的加载和编译功能,添加一个私有方法,该方法不能从simple_shader.js
文件之外访问:
activate() {
let gl = core.getGL();
gl.useProgram(this.mCompiledShader);
// bind vertex buffer
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer.get());
gl.vertexAttribPointer(this.mVertexPositionRef,
3, // each element is a 3-float (x,y.z)
gl.FLOAT, // data type is FLOAT
false, // if the content is normalized vectors
0, // number of bytes to skip in between elements
0); // offsets to the first element
gl.enableVertexAttribArray(this.mVertexPositionRef);
}
function loadAndCompileShader(id, shaderType) {
let shaderSource = null, compiledShader = null;
let gl = core.getGL();
// Step A: Get the shader source from index.html
let shaderText = document.getElementById(id);
shaderSource = shaderText.firstChild.textContent;
// Step B: Create shader based on type: vertex or fragment
compiledShader = gl.createShader(shaderType);
// Step C: Compile the created shader
gl.shaderSource(compiledShader, shaderSource);
gl.compileShader(compiledShader);
// Step D: check for errors and return results (null if error)
// The log info is how shader compilation errors are displayed
// This is useful for debugging the shaders.
if (!gl.getShaderParameter(compiledShader, gl.COMPILE_STATUS)) {
throw new Error("A shader compiling error occurred: " +
gl.getShaderInfoLog(compiledShader));
}
return compiledShader;
}
注意,这个函数与您在shader_support.js
中创建的函数相同。
Note
本书中没有使用定义私有成员的 JavaScript #前缀,因为缺少子类的可见性使得继承中行为的专门化变得复杂。
- 最后,为
SimpleShader
类添加一个导出,这样它就可以在这个文件之外被访问和实例化:
export default SimpleShader;
Note
关键字default
表示名称SimpleShader
不能被import
语句改变。
游戏引擎的核心:core.js
核心包含整个游戏引擎共享的通用功能。这可以包括 WebGL(或 GPU)、共享资源、实用函数等的一次性初始化。
-
在新文件夹
src/engine
下创建一个core.js
的副本。 -
定义一个函数来创建
SimpleShader
对象的新实例: -
修改
initWebGL()
函数,仅关注 WebGL 的初始化,如下所示:
// The shader
let mShader = null;
function createShader() {
mShader = new SimpleShader(
"VertexShader", // IDs of the script tag in the index.html
"FragmentShader"); //
}
- 创建一个
init()
函数来执行引擎范围的系统初始化,包括初始化 WebGL 和顶点缓冲区,并创建一个简单着色器的实例:
// initialize the WebGL
function initWebGL(htmlCanvasID) {
let canvas = document.getElementById(htmlCanvasID);
// Get standard or experimental webgl and binds to the Canvas area
// store the results to the instance variable mGL
mGL = canvas.getContext("webgl2") ||
canvas.getContext("experimental-webgl2");
if (mGL === null) {
document.write("<br><b>WebGL 2 is not supported!</b>");
return;
}
}
- 修改 clear canvas 函数,将要清除的颜色参数化为:
function init(htmlCanvasID) {
initWebGL(htmlCanvasID); // setup mGL
vertexBuffer.init(); // setup mGLVertexBuffer
createShader(); // create the shader
}
- 导出相关函数供游戏引擎的其余部分访问:
function clearCanvas(color) {
mGL.clearColor(color[0], color[1], color[2], color[3]);
mGL.clear(mGL.COLOR_BUFFER_BIT); // clear to the color set
}
- 最后,删除
window.onload
函数,因为实际游戏的行为应该由游戏引擎的客户端定义,或者在本例中,由MyGame
类定义。
export { getGL, init, clearCanvas, drawSquare }
src/engine
文件夹现在包含了整个游戏引擎的基本源代码。由于对您的源代码进行了这些结构上的更改,游戏引擎现在可以作为一个简单的库,提供创建游戏或简单应用编程接口(API)的功能。目前,您的游戏引擎由三个支持 WebGL 初始化和绘制单位正方形的文件组成:core
模块、vertex_buffer
模块和SimpleShader
类。在剩余的项目中,新的源文件和功能将继续添加到该文件夹中。最终,这个文件夹将包含一个完整而复杂的游戏引擎。然而,这里定义的类似核心库的框架将继续存在。
客户端源代码
src/my_game
文件夹将包含游戏的实际源代码。如上所述,这个文件夹中的代码将被称为游戏引擎的客户端。现在,my_game
文件夹中的源代码将专注于通过利用您定义的简单游戏引擎的功能来绘制一个简单的正方形。
-
在
src/my_game
文件夹或者客户端文件夹中新建一个源文件,命名为my_game.js
。 -
按如下方式导入
core
模块: -
将
MyGame
定义为一个 JavaScript 类并添加一个constructor
来初始化游戏engine
,清除canvas,
并绘制正方形:
import * as engine from "../engine/core.js";
- 将
MyGame
对象的new
实例的创建绑定到window.onload
函数:
class MyGame {
constructor(htmlCanvasID) {
// Step A: Initialize the game engine
engine.init(htmlCanvasID);
// Step B: Clear the canvas
engine.clearCanvas([0, 0.8, 0, 1]);
// Step C: Draw the square
engine.drawSquare();
}
}
- 最后,修改
index.html
来加载游戏客户端,而不是在head
元素中加载引擎core.js
:
window.onload = function() {
new MyGame('GLCanvas');
}
<script type="module" src="./src/my_game/my_game.js"></script>
观察
虽然你完成的任务与前一个项目相同,但在这个项目中,你已经创建了一个支持游戏引擎后续修改和扩展的基础设施。您已经将源代码组织到单独的逻辑文件夹中,组织了类似单例的模块来实现引擎的核心功能,并获得了抽象支持未来设计和代码重用的SimpleShader
类的经验。现在,引擎由定义良好的模块和对象组成,具有清晰的接口方法,您现在可以专注于学习新概念、抽象概念以及将新的实现源代码集成到引擎中。
从 HTML 中分离出 GLSL
到目前为止,在你的项目中,GLSL 着色器代码嵌入在index.html
的 HTML 源代码中。这种组织意味着必须通过编辑index.html
文件来添加新的着色器。从逻辑上讲,GLSL 着色器应该与 HTML 源文件分开组织;从逻辑上来说,不断添加到index.html
将导致一个混乱和难以管理的文件,这将变得难以处理。由于这些原因,GLSL 着色器应该存储在单独的源文件中。
着色器源文件项目
这个项目演示了如何将 GLSL 着色器分离到单独的文件中。如图 2-9 所示,当运行这个项目时,一个白色的矩形显示在绿色的画布上,与之前的项目相同。这个项目的源代码在chapter2/2.5.shader_source_files
文件夹中定义。
图 2-9
运行着色器源文件项目
该项目的目标如下:
-
从 HTML 源代码中分离出 GLSL 着色器
-
演示如何在运行时加载着色器源代码文件
在简单着色器中加载着色器
不是将 GLSL 着色器作为 HTML 文档的一部分加载,而是可以修改SimpleShader
中的loadAndCompileShader()
来将 GLSL 着色器作为单独的文件加载:
-
继续上一个项目,打开
simple_shader.js
文件,编辑loadAndCompileShader()
函数,接收文件路径而不是 HTML ID: -
在
loadAndCompileShader()
函数中,用下面的XMLHttpRequest
替换步骤 A 中的 HTML 元素检索代码,以加载文件:
function loadAndCompileShader(filePath, shaderType)
let xmlReq, shaderSource = null, compiledShader = null;
let gl = core.getGL();
// Step A: Request the text from the given file location.
xmlReq = new XMLHttpRequest();
xmlReq.open('GET', filePath, false);
try {
xmlReq.send();
} catch (error) {
throw new Error("Failed to load shader: "
+ filePath
+ " [Hint: you cannot double click to run this project. "
+ "The index.html file must be loaded by a web-server.]");
return null;
}
shaderSource = xmlReq.responseText;
if (shaderSource === null) {
throw new Error("WARNING: Loading of:" + filePath + " Failed!");
return null;
}
请注意,文件加载将同步发生,web 浏览器将实际停止并等待xmlReq.open()
函数完成,返回打开文件的内容。如果文件丢失,打开操作将失败,响应文本将为空。
用于完成xmlReq.open()
功能的同步“停止和等待”是低效的,并且可能导致网页加载缓慢。当你了解到游戏资源的异步加载时,这个缺点将在第四章中得到解决。
Note
XMLHttpRequest()
对象需要一个正在运行的 web 服务器来完成 HTTP get 请求。这意味着您将能够在安装了“上线”扩展的 VS 代码中测试这个项目。但是,除非您的机器上运行着 web 服务器,否则您将无法通过直接双击index.html
文件来运行这个项目。这是因为没有服务器来满足 HTTP get 请求,GLSL 着色器加载将失败。
经过这次修改,SimpleShader
构造函数现在可以被修改为接收和转发文件路径到loadAndCompileShader()
函数,而不是 HTML 元素 id。
将着色器提取到它们自己的文件中
以下步骤从index.html
文件中检索顶点和片段着色器的源代码,并创建单独的文件来存储它们:
图 2-10
创建 glsl_shaders 文件夹
-
在
src
文件夹中新建一个包含所有 GLSL 着色器源代码文件的文件夹,命名为glsl_shaders
,如图 2-10 所示。 -
在
glsl_shaders
文件夹中创建两个新的文本文件,分别命名为simple_vs.glsl
和white_fs.glsl
,用于简单顶点着色器和白色片段着色器。
Note
所有 GLSL 着色器源代码文件都将以扩展名.glsl
结尾。着色器文件名中的vs
表示该文件包含顶点着色器,而fs
表示片段着色器。
-
通过编辑
simple_vs.glsl
并粘贴前一项目的index.html
文件中的顶点着色器代码,创建 GLSL 顶点着色器源代码: -
通过编辑
white_fs.glsl
并将片段着色器代码粘贴到上一个项目的index.html
文件中,创建 GLSL 片段着色器源代码:
attribute vec3 aVertexPosition; // Vertex shader expects one position
void main(void) {
// Convert the vec3 into vec4 for scan conversion and
// assign to gl_Position to pass the vertex to the fragment shader
gl_Position = vec4(aVertexPosition, 1.0);
}
precision mediump float; // precision for float computation
void main(void) {
// for every pixel called (within the square) sets
// constant color white with alpha-channel value of 1.0
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
清理 HTML 代码
由于顶点和片段着色器存储在单独的文件中,现在可以清理index.html
文件,使其仅包含 HTML 代码:
- 从
index.html
中删除所有的 GLSL 着色器代码,这样这个文件变成如下:
<!DOCTYPE html>
<html>
<head>
<title>Example 2.5: The Shader Source File Project</title>
<link rel ="icon" type ="image/x-icon" href="./favicon.png">
<!-- there are javascript source code contained in
the external source files
-->
<!-- Client game code -->
<script type="module" src="./src/my_game/my_game.js"></script>
</head>
<body>
<canvas id="GLCanvas" width="640" height="480">
<!-- GLCanvas is the area we will draw in: a 640x480 area -->
Your browser does not support the HTML5 canvas.
<!-- this message will show only if WebGL clearing failed -->
</canvas>
</body>
</html>
注意index.html
不再包含任何 GLSL 着色器代码,只包含一个对 JavaScript 代码的引用。有了这种组织,index.html
文件可以被适当地认为是表示网页,在该网页中,从现在开始,您不需要编辑该文件来修改着色器。
- 修改
core.js
中的createShader()
函数以加载着色器文件,而不是 HTML 元素 id:
function createShader() {
mShader = new SimpleShader(
"src/glsl_shaders/simple_vs.glsl", // Path to VertexShader
"src/glsl_shaders/white_fs.glsl"); // Path to FragmentShader
}
源代码组织
引擎源代码中逻辑组件的分离已经发展到以下状态:
-
这是一个包含 HTML 代码的文件,它定义了游戏网页上的画布,并加载了游戏的源代码。
-
这个文件夹包含了所有绘制游戏元素的 GLSL 着色器源代码文件。
-
这是包含所有游戏引擎源代码文件的文件夹。
-
这是包含实际游戏源代码的客户端文件夹。
更改着色器和控制颜色
由于 GLSL 着色器存储在单独的源代码文件中,现在可以编辑或替换着色器,只需对其余源代码进行相对较小的更改。下一个项目演示了这种便利性,它用一个可以参数化为任何颜色的着色器来替换限制性的恒定白色片段着色器white_fs.glsl
。
参数化片段着色器项目
这个项目用一个支持任何颜色绘图的simple_fs.glsl
替换了white_fs.glsl
。图 2-11 显示了运行参数化片段着色器项目的输出;请注意,红色方块取代了先前项目中的白色方块。这个项目的源代码在chapter2/2.6.parameterized_fragment_shader
文件夹中定义。
图 2-11
运行参数化片段着色器项目
该项目的目标如下:
-
获取在源代码结构中创建 GLSL 着色器的经验
-
了解
uniform
变量并使用颜色参数定义片段着色器
定义 simple_fs.glsl 片段着色器
需要创建一个新的片段着色器来支持为每个绘制操作更改像素颜色。这可以通过在src/glsl_shaders
文件夹中创建一个新的 GLSL 片段着色器并命名为simple_fs.glsl
来完成。编辑该文件以添加以下内容:
precision mediump float; // precision for float computation
// Color of pixel
uniform vec4 uPixelColor;
void main(void) {
// for every pixel called sets to the user specified color
gl_FragColor = uPixelColor;
}
回想一下,GLSL attribute
关键字标识了针对每个顶点位置而变化的数据。在这种情况下,uniform
关键字表示变量对于所有顶点都是常数。可以通过 JavaScript 设置uPixelColor
变量来控制最终的像素颜色。precision mediump
关键字定义了计算的浮点精度。
Note
浮点精度以计算的准确性换取性能。有关 WebGL 的更多信息,请参阅第一章中的参考资料。
修改 SimpleShader 以支持颜色参数
现在可以修改SimpleShader
类来访问新的uPixelColor
变量:
-
编辑
simple_shader.js
并添加一个新的实例变量,用于引用构造函数中的uPixelColor
: -
将代码添加到构造函数的末尾,以创建对
uPixelColor
的引用:
this.mPixelColorRef = null; // pixelColor uniform in fragment shader
- 修改着色器激活以允许通过
uniform4fv()
功能设置像素颜色:
// Step E: Gets uniform variable uPixelColor in fragment shader
this.mPixelColorRef = gl.getUniformLocation(
this.mCompiledShader, "uPixelColor");
activate(pixelColor) {
let gl = core.getGL();
gl.useProgram(this.mCompiledShader);
// bind vertex buffer
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer.get());
gl.vertexAttribPointer(this.mVertexPositionRef,
3, // each element is a 3-float (x,y.z)
gl.FLOAT, // data type is FLOAT
false, // if the content is normalized vectors
0, // number of bytes to skip in between elements
0); // offsets to the first element
gl.enableVertexAttribArray(this.mVertexPositionRef);
// load uniforms
gl.uniform4fv(this.mPixelColorRef, pixelColor);
}
gl.uniform4fv()
函数将四个浮点值从pixelColor
浮点数组复制到由mPixelColorRef
或simple_fs.glsl
片段着色器中的uPixelColor
引用的 WebGL 位置。
使用新着色器进行绘制
要测试simple_fs.glsl
,请修改core.js
模块,使用新的simple_fs
创建一个简单着色器,并在使用新着色器绘制时使用参数化的颜色:
function createShader() {
mShader = new SimpleShader(
"src/glsl_shaders/simple_vs.glsl", // Path to the VertexShader
"src/glsl_shaders/simple_fs.glsl"); // Path to the FragmentShader
}
function drawSquare(color) {
// Step A: Activate the shader
mShader.activate(color);
// Step B: Draw with currently activated geometry and shader
mGL.drawArrays(mGL.TRIANGLE_STRIP, 0, 4);
}
最后,编辑MyGame
类的constructor
以在绘制正方形时包含一种颜色,在本例中是红色:
// Step C: Draw the square in red
engine.drawSquare([1, 0, 0, 1]);
请注意,新的simple_fs.glsl
(而不是white_fs
)着色器现在需要一个颜色值,即四个浮点数的数组,并且在激活着色器时传递绘图颜色很重要。有了新的simple_fs
,你现在可以尝试用任何想要的颜色画正方形。
正如您在本项目中所体验到的,当游戏引擎被扩展或修改时,源代码结构支持简单和本地化的更改。在这种情况下,只需要对simple_shader.js
文件进行修改,并对core.js
和my_game.js
进行微小的修改。这展示了正确封装和源代码组织的好处。
摘要
至此,游戏引擎很简单,只支持 WebGL 的初始化和一个彩色方块的绘制。然而,通过本章中的项目,你已经获得了为游戏引擎建立良好基础所需的技术经验。您还构建了源代码,通过对现有代码基础的有限修改来支持进一步的复杂性,并且您现在准备进一步封装游戏引擎的功能,以促进附加功能。下一章将关注于在游戏引擎中建立一个合适的框架来支持更灵活和可配置的绘图。
三、世界上的绘画对象
完成本章后,您将能够
-
创建并绘制多个矩形对象
-
控制创建的矩形对象的位置、大小、旋转和颜色
-
定义从中进行绘制的坐标系
-
在画布上定义要绘制到的目标子区域
-
使用
Renderable
对象、变换操作符和相机的抽象表示
介绍
理想情况下,视频游戏引擎应该提供适当的抽象来支持在有意义的上下文中设计和构建游戏。例如,当设计一个足球游戏时,游戏引擎应该提供适当的工具来支持在足球场上奔跑的运动员的设计,而不是具有固定的 1.0 绘图范围的单个正方形。这种高级抽象要求用数据隐藏和有意义的函数封装基本操作,以设置和接收期望的结果。
虽然这本书是关于构建游戏引擎的抽象,但这一章的重点是创建支持绘图的基本抽象。基于足球游戏示例,对在有效游戏引擎中绘图的支持可能包括轻松创建足球运动员、控制他们的大小和方向,以及允许他们在足球场上移动和绘图的能力。此外,为了支持正确的表示,游戏引擎必须允许在画布上绘制特定的子区域,以便可以在不同的子区域显示不同的游戏状态,例如在一个子区域中显示足球场,而在另一个子区域中显示运动员统计数据和得分。
本章确定了基本绘图操作的适当抽象实体,介绍了基于基础数学的运算符来控制绘图,概述了用于配置画布以支持子区域绘图的 WebGL 工具,定义了实现这些概念的 JavaScript 类,并将这些实现集成到游戏引擎中,同时保持了源代码的组织结构。
封装图
尽管绘图能力是游戏引擎最基本的功能之一,但绘图是如何实现的细节通常会分散游戏编程的注意力。例如,在足球比赛中创建、控制位置和绘制足球运动员是很重要的。然而,暴露每个玩家实际上是如何定义的细节(通过形成三角形的顶点的集合)会很快淹没游戏开发过程并使其复杂化。因此,对于游戏引擎来说,为绘图操作提供定义良好的抽象接口是非常重要的。
有了组织良好的源代码结构,就有可能通过对相应文件夹进行本地化更改来实现新概念,从而逐渐地、系统地增加游戏引擎的复杂性。第一个任务是扩展引擎以支持绘图的封装,这样就有可能将绘图操作作为一个逻辑实体或一个可以渲染的对象来操作。
Note
在计算机图形和视频游戏的背景下, render 这个词指的是改变与抽象表示相对应的像素颜色的过程。例如,在前一章中,你学习了如何渲染一个正方形。
可渲染对象项目
这个项目引入了Renderable
类来封装绘图操作。在接下来的几个项目中,您将学习更多的支持概念,以细化Renderable
类的实现,从而可以创建和操作多个实例。图 3-1 显示了运行可渲染对象项目的输出。这个项目的源代码在chapter3/3.1.renderable_objects
文件夹中定义。
图 3-1
运行可渲染对象项目
该项目的目标如下:
-
重新组织源代码结构以预期功能的增加
-
支持游戏引擎内部资源共享
-
通过
index.js
文件为游戏开发者引入系统化的界面 -
通过首先抽象相关的绘图功能,开始构建封装绘图操作的类的过程
-
演示创建多个
Renderable
对象的能力
源代码结构重组
在向游戏引擎引入额外的功能之前,认识到前一个项目中引擎源代码组织的一些不足是很重要的。特别要注意以下几点:
-
core.js
源代码文件包含 WebGL 接口、引擎初始化和绘图功能。这些应该模块化,以支持系统复杂性的预期增加。 -
应该定义一个系统来支持游戏引擎内部资源的共享。例如,
SimpleShader
负责从游戏引擎到从simple_vs.glsl
和simple_fs.glsl
源代码文件编译的 GLSL 着色器的接口。由于编译后的着色器只有一个副本,所以只需要有一个SimpleShader
对象的实例。游戏引擎应该通过允许方便地创建和共享对象来促进这一点。 -
正如您所经历的,JavaScript
export
语句是隐藏详细实现的优秀工具。然而,在一个大型复杂的系统中,例如您将要开发的游戏引擎,确定从大量文件中导入哪些类或模块可能会令人困惑和不知所措,这也是事实。应该提供一个易于操作的系统化界面,使得游戏开发者、游戏引擎的用户可以不受这些细节的影响。
在下一节中,游戏引擎源代码将被重新组织以解决这些问题。
定义特定于 WebGL 的模块
源代码重组的第一步是识别和隔离内部功能,游戏引擎的客户端不应访问这些功能:
-
在您的项目中,在
src/engine
文件夹下,创建一个新文件夹,并将其命名为core
。从现在开始,这个文件夹将包含游戏引擎内部的所有功能,并且不会导出给游戏开发者。 -
您可以将先前项目中的
vertex_buffer.js
源代码文件剪切并粘贴到src/engine/core
文件夹中。图元顶点的细节是游戏引擎内部的,并且不应该被游戏引擎的客户端看到或访问。 -
在
src/engine/core
文件夹中新建一个源代码文件,命名为gl.js
,定义 WebGL 的初始化和访问方法:
"use strict"
let mCanvas = null;
let mGL = null;
function get() { return mGL; }
function init(htmlCanvasID) {
mCanvas = document.getElementById(htmlCanvasID);
if (mCanvas == null)
throw new Error("Engine init [" +
htmlCanvasID + "] HTML element id not found");
// Get standard or experimental webgl and binds to the Canvas area
// store the results to the instance variable mGL
mGL = mCanvas.getContext("webgl2") ||
mCanvas.getContext("experimental-webgl2");
if (mGL === null) {
document.write("<br><b>WebGL 2 is not supported!</b>");
return;
}
}
export {init, get}
请注意,init()
函数与上一个项目中core.js
中的initWebGL()
函数相同。与之前的core.js
源代码文件不同,gl.js
文件只包含特定于 WebGL 的功能。
定义内部着色器资源共享的系统
由于从simple_vs.glsl
和simple_fs.glsl
源代码文件中仅创建和编译了 GLSL 着色器的单个副本,因此在游戏引擎中仅需要SimpleShader
对象的单个副本来与编译后的着色器接口。现在,您将创建一个简单的资源共享系统,以支持将来添加不同类型的着色器。
在src/engine/core
文件夹下新建一个源代码文件,命名为shader_resources.js
,定义SimpleShader
的创建和访问方法。
Note
回想一下前一章,SimpleShader
类是在位于src/engine
文件夹中的simple_shader.js
文件中定义的。记住要从之前的项目中复制所有相关的源代码文件。
"use strict";
import SimpleShader from "../simple_shader.js";
// Simple Shader
let kSimpleVS = "src/glsl_shaders/simple_vs.glsl"; // to VertexShader
let kSimpleFS = "src/glsl_shaders/simple_fs.glsl"; // to FragmentShader
let mConstColorShader = null;
function createShaders() {
mConstColorShader = new SimpleShader(kSimpleVS, kSimpleFS);
}
function init() {
createShaders();
}
function getConstColorShader() { return mConstColorShader; }
export {init, getConstColorShader}
Note
引用常量值的变量名称以小写字母“k”开头,如kSimpleVS
。
由于shader_resources
模块位于src/engine/core
文件夹中,定义的着色器在游戏引擎的客户端内共享,并且不能从游戏引擎的客户端访问。
为游戏开发者定义一个访问文件
您将定义一个引擎访问文件index.js
,以实现游戏引擎的基本功能,并提供与头文件C++
、Java 中的import
语句或 C#中的using
语句类似的功能,无需深入了解引擎源代码结构即可轻松访问这些功能。也就是说,通过导入index.js
,客户端可以从引擎访问所有的组件和功能来构建他们的游戏。
-
在
src/engine
文件夹中创建index.js
文件;import
从gl.js
、vertex_buffer.js
、shader_resources.js
;并定义init()
函数,通过调用三个导入模块对应的init()
函数来初始化游戏引擎: -
定义
clearCanvas()
函数来清除绘图画布:
// local to this file only
import * as glSys from "./core/gl.js";
import * as vertexBuffer from "./core/vertex_buffer.js";
import * as shaderResources from "./core/shader_resources.js";
// general engine utilities
function init(htmlCanvasID) {
glSys.init(htmlCanvasID);
vertexBuffer.init();
shaderResources.init();
}
- 现在,为了正确地向游戏引擎的客户端公开
Renderable
符号,请确保导入该类以便正确地导出该类。下一节将详细介绍Renderable
类。
function clearCanvas(color) {
let gl = glSys.get();
gl.clearColor(color[0], color[1], color[2], color[3]);
gl.clear(gl.COLOR_BUFFER_BIT); // clear to the color set
}
- 最后,记住为游戏引擎的客户端导出正确的符号和功能:
// general utilities
import Renderable from "./renderable.js";
export default {
// Util classes
Renderable,
// functions
init, clearCanvas
}
通过对这个index.js
文件进行适当的维护和更新,游戏引擎的客户端,即游戏开发者,可以简单地从index.js
文件导入,以获得对整个游戏引擎功能的访问,而无需了解任何源代码结构。最后,请注意engine/src/core
文件夹中定义的glSys
、vertexBuffer
和shaderResources
内部功能不是由index.js
导出的,因此游戏开发者无法访问。
可渲染的类
最后,您准备定义Renderable
类来封装绘图过程:
-
通过在
src/engine
文件夹中创建一个新的源代码文件来定义游戏引擎中的Renderable
类,并将该文件命名为renderable.js
。 -
打开
renderable.js
,从gl.js
和shader_resources.js
导入,用构造函数定义Renderable
类,初始化对着色器和颜色实例变量的引用。注意,着色器是对在shader_resources
中定义的共享SimpleShader
实例的引用。 -
为
Renderable
定义一个draw()
函数:
import * as glSys from "./core/gl.js";
import * as shaderResources from "./core/shader_resources.js";
class Renderable {
constructor() {
this.mShader = shaderResources.getConstColorShader();
this.mColor = [1, 1, 1, 1]; // color of pixel
}
... implementation to follow ...
}
draw() {
let gl = glSys.get();
this.mShader.activate(this.mColor);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
请注意,在使用gl.drawArrays()
函数发送顶点之前,通过调用activate()
函数来激活 GPU 中适当的 GLSL 着色器是非常重要的。
-
为 color 实例变量定义 getter 和 setter 函数:
-
默认导出
Renderable
符号,以确保该标识符不能被重命名:
setColor(color) {this.mColor = color; }
getColor() { return this.mColor; }
export default Renderable;
虽然这个例子很简单,但是现在可以用不同的颜色创建和绘制多个Renderable
对象的实例。
测试可呈现对象
为了测试MyGame
中的Renderable
对象,白色和红色实例被创建并绘制如下:
// import from engine/index.js for all engine symbols
import engine from "../engine/index.js";
class MyGame {
constructor(htmlCanvasID) {
// Step A: Initialize the webGL Context
engine.init(htmlCanvasID);
// Step B: Create the Renderable objects:
this.mWhiteSq = new engine.Renderable();
this.mWhiteSq.setColor([1, 1, 1, 1]);
this.mRedSq = new engine.Renderable();
this.mRedSq.setColor([1, 0, 0, 1]);
// Step C: Draw!
engine.clearCanvas([0, 0.8, 0, 1]); // Clear the canvas
// Step C1: Draw Renderable objects with the white shader
this.mWhiteSq.draw();
// Step C2: Draw Renderable objects with the red shader
this.mRedSq.draw();
}
}
请注意,import
语句被修改为从引擎访问文件index.js
导入。此外,MyGame
构造函数被修改为包括以下步骤:
-
步骤 A 初始化
engine
。 -
步骤 B 创建了两个
Renderable
实例,并相应地设置了对象的颜色。 -
步骤 C 清除画布;步骤 C1 和 C2 简单地调用了白色和红色方块各自的
draw()
函数。虽然两个方块都已绘制,但现在,您只能在画布中看到最后一个绘制的方块。详情请参考下面的讨论。
观察
运行项目,你会注意到只有红色方块是可见的!发生的情况是两个方块被画到了同一个位置。由于大小相同,这两个正方形完全重叠在一起。因为红色方块是最后绘制的,所以它会覆盖白色方块的所有像素。您可以通过注释掉红色正方形的绘图(注释掉线条mRedSq.draw()
)并重新运行项目来验证这一点。一个有趣的观察是出现在前面的物体被画在最后(红色方块)。当你使用透明的时候,你将会利用这个观察。
这个简单的观察引出了您的下一个任务——允许多个Renderable
实例同时可见。Renderable
对象的每个实例需要支持在不同位置以不同大小和方向绘制的能力,这样它们就不会彼此重叠。
变换可渲染对象
需要一种机制来操纵Renderable
对象的位置、大小和方向。在接下来的几个项目中,您将了解如何使用矩阵变换来*移或移动对象的位置,缩放对象的大小,以及在画布上更改对象的方向或旋转对象。这些操作是对象操作中最直观的操作。然而,在实现转换矩阵之前,需要快速回顾一下矩阵的操作和功能。
作为变换运算符的矩阵
在我们开始之前,重要的是要认识到矩阵和变换是数学中的一般主题领域。以下讨论并不试图全面涵盖这些主题。相反,从游戏引擎需要什么的角度来看,重点是相关概念和操作符的小集合。这样,覆盖面是如何利用运营商,而不是理论。如果你对矩阵的细节以及它们与计算机图形的关系感兴趣,请参考第一章中的讨论,在那里你可以通过钻研线性代数和计算机图形的相关书籍来了解更多关于这些主题的内容。
一个矩阵是由一个由 m 行 n 列 2D 数字组成的数组。为了这个游戏引擎的目的,你将专门使用 4×4 矩阵。虽然 2D 游戏引擎可以使用 3×3 矩阵,但 4×4 矩阵用于支持将在后面章节中介绍的功能。在许多强大的应用中,4×4 矩阵可以被构造为顶点位置的变换算子。这些操作符中最重要和最直观的是*移、缩放、旋转和恒等操作符。
图 3-2
将正方形*移 T(tx,ty)
- *移算子
T(tx,ty)
,如图 3-2 所示,将给定的顶点位置从(x,y)*移或移动到(x+tx,y+ty)。请注意,T(0,0)
不会改变给定顶点位置的值,是累积*移操作的方便初始值。
图 3-3
用 S(sx,sy)缩放正方形
- 缩放操作符
S(sx, sy)
,如图 3-3 所示,将给定的顶点位置从(x,y)缩放到(x×sx,y×sy)。请注意,S(1, 1)
不会改变给定顶点位置的值,是累积缩放操作的一个方便的初始值。
图 3-4
将正方形旋转 R(θ)
- 旋转操作符
R(
θ)
如图 3-4 所示,相对于原点旋转给定的顶点位置。
在旋转的情况下,R(0)
不会改变给定顶点的值,是累积旋转操作的方便初始值。θ值通常用弧度(而不是度数)表示。
- 恒等运算符
I
不会影响给定的顶点位置。该运算符主要用于初始化。
例如,一个 4×4 的单位矩阵看起来如下:
数学上,矩阵变换运算符通过矩阵向量乘法对顶点进行运算。为了支持这个操作,顶点位置 p = ( x 、 y 、 z )必须表示为如下的 4x1 向量:
Note
z 分量是顶点位置的第三维或深度信息。大多数情况下,您应该将 z 分量保留为 0。
例如,如果位置p'
是*移运算符T
对顶点位置p
进行运算的结果,那么从数学上讲,p'
将通过以下方式计算:
矩阵运算符的串联
多个矩阵运算符可以连接或组合成一个运算符,同时保留与原始运算符相同的转换特性。例如,您可能想要在给定的顶点位置上应用缩放操作符S
,然后是旋转操作符R
,最后是*移操作符T
,或者使用下面的
来计算p'
或者,您可以通过连接所有的转换操作符来计算一个新的操作符M
,如下所示:
然后在顶点位置p
操作M
,如下,产生相同的结果:
M
操作符是记录和重新应用多个操作符结果的一种方便有效的方式。
最后,注意当使用转换操作符时,操作的顺序很重要。例如,缩放操作后跟随*移操作通常不同于*移后跟随缩放,或者通常:
glMatrix 库
矩阵运算符和运算的细节至少可以说是很重要的。开发一个完整的矩阵库很耗时,也不是本书的重点。幸运的是,在公共领域中有许多开发良好、记录完善的矩阵库。图书馆就是这样一个例子。要将该库集成到源代码结构中,请按照下列步骤操作:
图 3-5
下载 glMatrix 库
-
在
src
文件夹下新建一个文件夹,命名为lib
。 -
进入
http://glMatrix.net
,如图 3-5 所示,将生成的glMatrix.js
源文件下载、解压并保存到新的lib
文件夹中。
本书所有项目都基于【2.2.2 版。
- 作为一个游戏引擎和客户端游戏开发者都必须访问的库,您将通过在加载
my_game.js
之前添加以下内容来加载主index.html
中的源文件:
<!-- external library -->
<script type="text/javascript" src="src/lib/gl-matrix.js"></script>
<!-- our game -->
<script type="module" src="./src/my_game/my_game.js"></script>
矩阵变换项目
这个项目介绍并演示了如何使用变换矩阵作为操作符来操作画布上绘制的Renderable
对象的位置、大小和方向。通过这种方式,现在可以将一个Renderable
绘制到任何位置,具有任何大小和任何方向。图 3-6 显示了运行矩阵变换项目的输出。这个项目的源代码在chapter3/3.2.matrix_transform
文件夹中定义。
图 3-6
运行矩阵转换项目
该项目的目标如下:
-
引入变换矩阵作为绘制 a
Renderable
的运算符 -
理解如何使用变换操作符来操作
Renderable
修改顶点着色器以支持变换
如前所述,矩阵变换运算符对几何图形的顶点进行运算。顶点着色器是从 WebGL 上下文传入所有顶点的地方,也是应用变换操作的最方便位置。
您将继续使用之前的项目来支持顶点着色器中的变换操作符:
- 编辑
simple_vs.glsl
以声明一个统一的 4×4 矩阵:
Note
回想一下第二章的讨论,glsl 文件包含 OpenGL 着色语言(GLSL)指令,这些指令将被加载到 GPU 并由 GPU 执行。你可以通过参考第一章末尾提供的 WebGL 和 OpenGL 参考找到更多关于 GLSL 的信息。
// to transform the vertex position
uniform mat4 uModelXformMatrix;
回想一下,GLSL 着色器中的关键字uniform
声明了一个变量,该变量的值不会因该着色器中的所有顶点而改变。在这种情况下,uModelXformMatrix
变量是所有顶点的变换操作符。
Note
GLSL 统一变量名总是以小写字母“u”开头,如uModelXformMatrix
。
- 在
main()
功能中,将uModelXformMatrix
应用到当前参考的顶点位置:
gl_Position = uModelXformMatrix * vec4(aVertexPosition, 1.0);
请注意,该运算直接来自对矩阵变换运算符的讨论。将aVertexPosition
转换为vec4
的原因是为了支持矩阵向量乘法。
通过这个简单的修改,单位正方形的顶点位置将由uModelXformMatrix
操作符操作,因此正方形可以被绘制到不同的位置。现在的任务是设置SimpleShader
将适当的转换操作符加载到uModelXformMatrix
中。
修改 SimpleShader 以加载变换运算符
请遵循以下步骤:
-
编辑
simple_shader.js
并添加一个实例变量来保存对顶点着色器中uModelXformMatrix
矩阵的引用: -
在步骤 E 下的
SimpleShader
构造函数的末尾,将引用设置为uPixelColor
后,添加以下代码来初始化该引用:
this.mModelMatrixRef = null;
- 修改
activate()
函数以接收第二个参数,并通过mModelMatrixRef
将该值加载到uModelXformMatrix
:
// Step E: Gets a reference to uniform variables in fragment shader
this.mPixelColorRef = gl.getUniformLocation(
this.mCompiledShader, "uPixelColor");
this.mModelMatrixRef = gl.getUniformLocation(
this.mCompiledShader, "uModelXformMatrix");
activate(pixelColor, trsMatrix) {
let gl = glSys.get();
gl.useProgram(this.mCompiledShader);
... identical to previous code ...
// load uniforms
gl.uniform4fv(this.mPixelColorRef, pixelColor);
gl.uniformMatrix4fv(this.mModelMatrixRef, false, trsMatrix);
}
gl.uniformMatrix4fv()
函数将值从trsMatrix
复制到顶点着色器位置,该位置由顶点着色器中的this.mModelMatrixRef
或uModelXfromMatrix
操作符确定。变量的名字trsMatrix
表明它应该是一个矩阵运算符,包含*移(T
)、旋转(R
)和缩放(S
或TRS
)的级联结果。
修改可呈现类以设置变换运算符
编辑renderable.js
来修改draw()
函数,以接收和转发一个变换操作符到mShader.activate()
函数来加载到 GLSL 着色器:
draw(trsMatrix) {
let gl = glSys.get();
this.mShader.activate(this.mColor, trsMatrix);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
这样,当顶点着色器处理单位正方形的顶点时,uModelXformMatrix
将包含适当的操作符,用于转换顶点,从而在所需的位置、大小和旋转角度绘制正方形。
测试转换
既然游戏引擎支持变换,您需要修改客户端代码来使用它进行绘制:
-
编辑
my_game.js
;在步骤 C 之后,代替激活和绘制两个正方形,替换步骤 C1 和 C2 来创建一个新的恒等式变换操作符,trsMatrix
: -
将矩阵连接到一个变换操作符,该操作符实现*移(
T
)、旋转(R
)和缩放(S
或TRS
):
// create a new identify transform operator
let trsMatrix = mat4.create();
// Step D: compute the white square transform
mat4.translate(trsMatrix, trsMatrix, vec3.fromValues(-0.25, 0.25, 0.0));
mat4.rotateZ(trsMatrix, trsMatrix, 0.2); // rotation is in radian
mat4.scale(trsMatrix, trsMatrix, vec3.fromValues(1.2, 1.2, 1.0));
// Step E: draw the white square with the computed transform
this.mWhiteSq.draw(trsMatrix);
步骤 D 串联T(-0.25, 0.25)
,向左上移动;用R(0.2)
,顺时针旋转 0.2 弧度;还有S(1.2, 1.2)
,尺寸增加了 1.2 倍。串联顺序首先应用缩放操作符,然后是旋转,最后是*移操作,即trsMatrix=TRS
。在步骤 E 中,用trsMatrix
操作符或 1.2×1.2 的白色矩形稍微旋转并位于中心的左上方来绘制Renderable
对象。
- 最后,步骤 F 定义了
trsMatrix
操作符来绘制一个 0.4×0.4 的正方形,该正方形被旋转了 45 度,并位于画布中心的右下方,步骤 G 绘制红色正方形:
// Step F: compute the red square transform
mat4.identity(trsMatrix); // restart
mat4.translate(trsMatrix, trsMatrix, vec3.fromValues(0.25, -0.25, 0.0));
mat4.rotateZ(trsMatrix, trsMatrix, -0.785); // about -45-degrees
mat4.scale(trsMatrix, trsMatrix, vec3.fromValues(0.4, 0.4, 1.0));
// Step G: draw the red square with the computed transform
this.mRedSq.draw(trsMatrix);
观察
运行项目,您应该会看到画布上绘制了相应的白色和红色矩形。你可以通过改变值来获得操作者的一些直觉;例如,将正方形移动和缩放到不同的位置,使用不同的大小。您可以通过移动相应的代码行来尝试更改串联的顺序;例如,将mat4.scale()
移动到mat4.translate()
之前。您会注意到,一般来说,转换后的结果与您的直觉不一致。在本书中,你将总是按照固定的TRS
顺序应用变换操作符。变换运算符的这种排序符合典型的人类直觉。大多数支持转换操作的图形 API 和应用都遵循TRS
操作顺序。
既然您已经了解了如何使用矩阵变换操作符,那么是时候对它们进行抽象并隐藏它们的细节了。
封装转换操作符
在前一个项目中,变换操作符是根据矩阵直接计算的。虽然结果很重要,但计算涉及令人分心的细节和重复的代码。这个项目指导您遵循良好的编码实践,通过用类隐藏详细的计算来封装转换操作符。这样,您可以通过支持进一步扩展来保持游戏引擎的模块化和可访问性,同时保持可编程性。
转换对象项目
这个项目定义了Transform
类来提供一个逻辑接口,用于操作和隐藏矩阵变换操作符的细节。图 3-7 显示了运行矩阵变换项目的输出。请注意,这个项目的输出与前一个项目的输出相同。这个项目的源代码在chapter3/3.3.transform_objects
文件夹中定义。
图 3-7
运行转换对象项目
该项目的目标如下:
-
创建
Transform
类来封装矩阵转换功能 -
将
Transform
类集成到游戏引擎中 -
演示如何使用
Transform
对象
转换类
继续使用上一个项目:
-
通过在
src/engine
文件夹中创建一个新的源代码文件来定义游戏引擎中的Transform
类,并将该文件命名为transform.js
。 -
定义构造函数来初始化对应于操作符的实例变量:
mPosition
用于*移,mScale
用于缩放,mRotationInRad
用于旋转。 -
为每个运算符的值添加 getters 和 setters:
class Transform {
constructor() {
this.mPosition = vec2.fromValues(0, 0); // translation
this.mScale = vec2.fromValues(1, 1); // width (x), height (y)
this.mRotationInRad = 0.0; // in radians!
}
... implementation to follow ...
}
- 定义
getTRSMatrix()
函数来计算并返回连接的转换操作符TRS
:
// Position getters and setters
setPosition(xPos, yPos) { this.setXPos(xPos); this.setYPos(yPos); }
getPosition() { return this.mPosition; }
// ... additional get and set functions for position not shown
// Size setters and getters
setSize(width, height) {
this.setWidth(width);
this.setHeight(height);
}
getSize() { return this.mScale; }
// ... additional get and set functions for size not shown
// Rotation getters and setters
setRotationInRad(rotationInRadians) {
this.mRotationInRad = rotationInRadians;
while (this.mRotationInRad > (2 * Math.PI)) {
this.mRotationInRad -= (2 * Math.PI);
}
}
setRotationInDegree(rotationInDegree) {
this.setRotationInRad(rotationInDegree * Math.PI / 180.0);
}
// ... additional get and set functions for rotation not shown
getTRSMatrix() {
// Creates a blank identity matrix
let matrix = mat4.create();
// Step A: compute translation, for now z is always at 0.0
mat4.translate(matrix, matrix,
vec3.fromValues(this.getXPos(), this.getYPos(), 0.0));
// Step B: concatenate with rotation.
mat4.rotateZ(matrix, matrix, this.getRotationInRad());
// Step C: concatenate with scaling
mat4.scale(matrix, matrix,
vec3.fromValues(this.getWidth(), this.getHeight(), 1.0));
return matrix;
}
这段代码类似于上一个项目中my_game.js
的步骤 D 和 F。串联运算符TRS
首先执行缩放,然后是旋转,最后是*移。
- 最后,记得导出新定义的
Transform
类:
export default Transform;
可转换的可呈现类
通过集成Transform
类,Renderable
对象现在可以有位置、大小(缩放)和方向(旋转)。这种集成可以通过以下步骤轻松完成:
-
编辑
renderable.js
并添加一个新的实例变量来引用构造函数中的Transform
对象: -
为转换运算符定义一个访问器:
this.mXform = new Transform(); // transform operator for the object
- 修改
draw()
函数,在绘制单位正方形之前,通过mXform
对象的trsMatrix
操作符激活着色器:
getXform() { return this.mXform; }
draw() {
let gl = glSys.get();
this.mShader.activate(this.mColor, this.mXform.getTRSMatrix());
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
通过这个简单的修改,Renderable
对象将被绘制成具有由它自己的变换操作符的值定义的特征。
修改引擎访问文件以导出转换
保持引擎访问文件index.js
为最新是很重要的,以便游戏开发者可以访问新定义的Transform
类:
-
编辑
index.js
;从新定义的transform.js
文件导入: -
导出
Transform
供客户端访问:
// general utilities
import Transform from "./transform.js";
import Renderable from "./renderable.js";
export default {
// Util classes
Transform, Renderable,
// functions
init, clearCanvas
}
修改绘图以支持变换对象
为了测试Transform
和改进的Renderable
类,可以修改MyGame
构造函数来相应地设置每个Renderable
对象中的转换操作符:
// Step D: sets the white Renderable object's transform
this.mWhiteSq.getXform().setPosition(-0.25, 0.25);
this.mWhiteSq.getXform().setRotationInRad(0.2); // In Radians
this.mWhiteSq.getXform().setSize(1.2, 1.2);
// Step E: draws the white square (transform behavior in the object)
this.mWhiteSq.draw();
// Step F: sets the red square transform
this.mRedSq.getXform().setXPos(0.25); // alternative to setPosition
this.mRedSq.getXform().setYPos(-0.25);// setX/Y separately
this.mRedSq.getXform().setRotationInDegree(45); // this is in Degree
this.mRedSq.getXform().setWidth(0.4); // alternative to setSize
this.mRedSq.getXform().setHeight(0.4);// set width/height separately
// Step G: draw the red square (transform in the object)
this.mRedSq.draw();
运行项目,观察与上一个项目相同的输出。您现在可以在画布中的任何位置创建和绘制一个Renderable
,并且 transform 操作符现在已经被正确封装。
摄影机变换和视口
当设计和构建一个视频游戏时,游戏设计者和程序员必须能够关注内在的逻辑和表现。为了促进这些方面,重要的是设计者和程序员可以在方便的维度和空间中制定解决方案。
例如,继续足球游戏的想法,考虑创建一个足球场的任务。场地有多大?测量单位是什么?一般来说,在构建游戏世界时,参考现实世界往往更容易设计出解决方案。在现实世界中,足球场大约有 100 米长。然而,在游戏或图形世界中,单位是任意的。因此,一个简单的解决方案可能是创建一个 100 米单位的场地和一个坐标空间,其中原点位于足球场的中心。以这种方式,球场的相对侧可以简单地由 x 值的符号来确定,并且在位置(0,1)绘制球员将意味着将球员从足球场的中心向右绘制 1 米。
一个相反的例子是构建一个类似国际象棋的棋盘游戏。基于原点位于电路板左下角的无单位 n×n 网格来设计解决方案可能更方便。在这种情况下,在位置(0,1)绘制棋子将意味着在棋盘左下角向右一个单元格或单位的位置绘制棋子。正如将要讨论的,定义特定坐标系的能力通常是通过计算和使用表示来自摄像机的视图的矩阵来实现的。
在所有情况下,为了支持游戏的正确表现,允许程序员控制内容在画布上的任何位置的绘制是很重要的。例如,您可能希望将足球场和球员绘制到一个子区域,并将小地图绘制到另一个子区域。这些轴对齐的矩形绘图区域或画布的子区域被称为视口。
在本节中,您将了解坐标系以及如何使用矩阵变换作为工具来定义符合 WebGL 固定 1 绘图范围的绘图区域。
坐标系和变换
2D 坐标系唯一地标识了 2D *面上的每个位置。本书中的所有项目都遵循笛卡尔坐标系,在该坐标系中,根据从称为原点的参考点的垂直距离来定义位置,如图 3-8 所示。测量距离的垂直方向被称为主轴。在 2D 空间中,这些是我们熟悉的 x 和 y 轴。
图 3-8
使用 2D 笛卡尔坐标系
建模和标准化设备坐标系
到目前为止,在本书中,你已经体验了两个不同的坐标系。第一个是定义顶点缓冲区中 1×1 正方形顶点的坐标系。这被称为建模坐标系,它定义了模型空间。对于每个几何对象,模型空间都是唯一的,就像单位正方形的情况一样。模型空间被定义为描述单个模型的几何形状。您使用的第二个坐标系是 WebGL 绘制的坐标系,其中 x 轴和 y 轴的范围限制为 1.0。这就是所谓的标准化设备坐标(NDC)系统。正如您所经历的,WebGL 总是绘制到 NDC 空间,并且 1.0 范围内的内容覆盖了画布中的所有像素。
建模转换通常由矩阵转换运算符定义,是将几何图形从其模型空间转换到另一个便于绘图的坐标空间的操作。在之前的项目中,simple_vs.glsl
中的uModelXformMatrix
变量是建模转换。如图 3-9 所示,在这种情况下,建模转换将单位正方形转换为 WebGL 的 NDC 空间。图 3-9 中标注有固定映射标签的最右边箭头从 WebGL NDC 指向画布坐标表示 WebGL 总是在画布中显示 NDC 空间的全部内容。
图 3-9
将广场从模型转换到 NDC 空间
世界坐标系统
尽管可以使用建模转换绘制到任何位置,但是将正方形绘制为矩形的不均衡缩放仍然是一个问题。此外,固定的-1.0 和 1.0 NDC 空间对于设计游戏来说不是一个方便的坐标空间。世界坐标(WC)系统描述了解决这些问题的方便的世界空间。为了方便和可读性,在本书的其余部分,WC 也将用于指代由特定世界坐标系定义的世界空间。
如图 3-10 所示,使用 WC 而不是固定的 NDC 空间,建模变换可以将模型变换到一个方便的坐标系中,该坐标系有助于游戏设计。对于足球游戏示例,世界空间维度可以是足球场的大小。与任何笛卡尔坐标系一样,WC 系统由参考位置及其宽度和高度定义。参考位置可以是 WC 的左下角或中心。
图 3-10
使用世界坐标(WC)系统
WC 是设计游戏的一个方便的坐标系统。但是,它并不是 WebGL 绘制的空间。因此,从 WC 到 NDC 的转换非常重要。在本书中,这种变换被称为相机变换。要完成这一变换,您必须构建一个操作符来将 WC 中心与 NDC(原点)的中心对齐,然后缩放 WC WxH 维度以匹配 NDC 的宽度和高度。请注意,NDC 空间具有-1 到+1 的恒定范围,因此具有 2x2 的固定维度。这样,相机变换就是简单的一个*移,后面跟着一个缩放操作:
在这种情况下,(center.x, center.y)
和WxH
是 WC 系统的中心和尺寸。
视窗
视口是要绘制的区域。正如您所经历的,默认情况下,WebGL 将整个画布定义为用于绘图的视口。方便的是,WebGL 提供了一个函数来覆盖这个默认行为:
gl.viewport(
x, // x position of bottom-left corner of the area to be drawn
y, // y position of bottom-left corner of the area to be drawn
width, // width of the area to be drawn
height // height of the area to be drawn
);
gl.viewport()
功能为所有后续图形定义一个视口。图 3-11 用视口说明了摄像机的变换和绘制。
图 3-11
使用 WebGL 视口
摄影机变换和视口项目
这个项目演示了如何使用相机转换从任何所需的坐标位置绘制到画布或视口的任何子区域。图 3-12 显示了运行相机变换和视口项目的输出。这个项目的源代码在chapter3/3.4.camera_transform_and_viewport
文件夹中定义。
图 3-12
运行摄影机变换和视口项目
该项目的目标如下:
-
为了理解不同的坐标系
-
体验使用 WebGL 视口在画布中定义和绘制不同的子区域
-
为了理解相机变换
-
开始绘制到用户定义的世界坐标系
现在,您已经准备好修改游戏引擎,以支持相机转换来定义您自己的 WC 和相应的绘图视口。第一步是修改着色器以支持新的变换操作符。
修改顶点着色器以支持相机变换
添加对相机变换的支持需要相对较小的更改:
-
编辑
simple_vs.glsl
添加一个新的uniform
矩阵操作符来表示摄像机变换: -
确保在顶点着色器程序中对顶点位置应用操作符:
uniform mat4 uCameraXformMatrix;
gl_Position = uCameraXformMatrix *
uModelXformMatrix *
vec4(aVertexPosition, 1.0);
回想一下,矩阵运算的顺序很重要。在这种情况下,uModelXformMatrix
首先将顶点位置从模型空间转换到 WC,然后uCameraXformMatrix
从 WC 转换到 NDC。uModelxformMatrix
和uCameraXformMatrix
的顺序不能互换。
修改 SimpleShader 以支持相机变换
必须修改SimpleShader
对象,以访问相机变换矩阵并将其传递给顶点着色器:
-
编辑
simple_shader.js
,并在构造函数中添加一个实例变量,用于存储对simple_vs.glsl
中摄像机变换操作符的引用: -
在
SimpleShader
构造函数的末尾,在检索了对uModelXformMatrix
和uPixelColor
的引用之后,检索对摄像机变换操作符uCameraXformMatrix
的引用:
this.mCameraMatrixRef = null;
- 修改
activate
函数以接收相机变换矩阵并将其传递给着色器:
// Step E: Gets reference to uniform variables in fragment shader
this.mPixelColorRef = gl.getUniformLocation(
this.mCompiledShader, "uPixelColor");
this.mModelMatrixRef = gl.getUniformLocation(
this.mCompiledShader, "uModelXformMatrix");
this.mCameraMatrixRef = gl.getUniformLocation(
this.mCompiledShader, "uCameraXformMatrix");
activate(pixelColor, trsMatrix, cameraMatrix) {
let gl = glSys.get();
gl.useProgram(this.mCompiledShader);
... identical to previous code ...
// load uniforms
gl.uniform4fv(this.mPixelColorRef, pixelColor);
gl.uniformMatrix4fv(this.mModelMatrixRef, false, trsMatrix);
gl.uniformMatrix4fv(this.mCameraMatrixRef, false, cameraMatrix);
}
正如您之前看到的,gl.uniformMatrix4fv()
函数将cameraMatrix
的内容复制到uCameraXformMatrix
操作符中。
修改可渲染以支持摄影机变换
回想一下,着色器是在Renderable
类的draw()
函数中激活的;因此,Renderable
也必须被修改以接收和传递cameraMatrix
来激活着色器:
draw(cameraMatrix) {
let gl = glSys.get();
this.mShader.activate(this.mColor,
this.mXform.getTRSMatrix(), cameraMatrix);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
现在可以为绘图设置一个 WC,并在画布中定义一个子区域。
设计场景
如图 3-13 所示,出于测试目的,世界空间(WC)将被定义为以(20,60)为中心,尺寸为 20×10。将在 WC 的中心绘制两个旋转的正方形,一个 5x5 的蓝色正方形和一个 2×2 的红色正方形。为了验证坐标界限,将在每个 WC 角绘制一个颜色不同的 1×1 正方形。
图 3-13
设计一个支持绘图的 WC
如图 3-14 所示,WC 将被绘制到一个左下角位于(20,40)的视口中,尺寸为 600×300 像素。值得注意的是,为了让正方形按比例显示,WC 的宽高比必须与视口的宽高比相匹配。在这种情况下,WC 的长宽比为 20:10,这个 2:1 的比例与 600:300 的视口比例相匹配。
图 3-14
将厕所绘制到视口
请注意,以(20,60)为中心、尺寸为 20x10 的 WC 的细节,以及(20,40)左下角、尺寸为 600x300 的视口,都是随机选择的。这些只是可以证明实现正确性的合理值。
实施设计
将修改MyGame
类以实现该设计:
-
编辑
my_game.js
。在构造函数中,执行步骤 A 来初始化游戏引擎,执行步骤 B 来创建六个具有相应颜色的Renderable
对象(在中心绘制两个,在 WC 的每个角绘制四个)。 -
步骤 C 和 D 清除整个画布,设置视窗,并将视窗清除为不同的颜色:
constructor(htmlCanvasID) {
// Step A: Initialize the game engine
engine.init(htmlCanvasID);
// Step B: Create the Renderable objects:
this.mBlueSq = new engine.Renderable();
this.mBlueSq.setColor([0.25, 0.25, 0.95, 1]);
this.mRedSq = new engine.Renderable();
this.mRedSq.setColor([1, 0.25, 0.25, 1]);
this.mTLSq = new engine.Renderable();
this.mTLSq.setColor([0.9, 0.1, 0.1, 1]);
this.mTRSq = new engine.Renderable();
this.mTRSq.setColor([0.1, 0.9, 0.1, 1]);
this.mBRSq = new engine.Renderable();
this.mBRSq.setColor([0.1, 0.1, 0.9, 1]);
this.mBLSq = new engine.Renderable();
this.mBLSq.setColor([0.1, 0.1, 0.1, 1]);
... implementation to follow ...
}
// Step C: Clear the entire canvas first
engine.clearCanvas([0.9, 0.9, 0.9, 1]);
// get access to the gl connection to the GPU
let gl = glSys.get();
// Step D: Setting up Viewport
// Step D1: Set up the viewport: area on canvas to be drawn
gl.viewport(
20, // x position of bottom-left corner of the area to be drawn
40, // y position of bottom-left corner of the area to be drawn
600, // width of the area to be drawn
300); // height of the area to be drawn
// Step D2: set up the corresponding scissor area to limit clear area
gl.scissor(
20, // x position of bottom-left corner of the area to be drawn
40, // y position of bottom-left corner of the area to be drawn
600, // width of the area to be drawn
300); // height of the area to be drawn
// Step D3: enable scissor area, clear and then disable the scissor area
gl.enable(gl.SCISSOR_TEST);
engine.clearCanvas([0.8, 0.8, 0.8, 1.0]); // clear the scissor area
gl.disable(gl.SCISSOR_TEST);
步骤 D1 定义视口,步骤 D2 定义相应的剪刀区域。剪刀区域测试并限制要清除的区域。由于gl.scissor()
中涉及的测试计算量很大,因此在使用后会立即禁用。
- 步骤 E 通过连接适当的缩放和*移操作符来定义具有相机变换的 WC:
// Step E: Set up camera transform matrix
// assume camera position and dimension
let cameraCenter = vec2.fromValues(20, 60);
let wcSize = vec2.fromValues(20, 10);
let cameraMatrix = mat4.create();
// Step E1: after translation, scale to: -1 to 1: a 2x2 square at origin
mat4.scale(cameraMatrix, mat4.create(),
vec3.fromValues(2.0/wcSize[0], 2.0/wcSize[1], 1.0));
// Step E2: first to perform is to translate camera center to origin
mat4.translate(cameraMatrix, cameraMatrix,
vec3.fromValues(-cameraCenter[0], -cameraCenter[1], 0));
步骤 E1 定义缩放操作符S(2/W, 2/H)
,将 WC WxH 缩放到 NDC 2x2 尺寸,步骤 E2 定义*移操作符T(-center.x, -center.y)
,将 WC 与 NDC 中心对齐。请注意,串联顺序首先实现转换,然后是缩放运算符。这正是前面描述的相机变换,它将 WC 定义如下:
-
中心 : (20,60)
-
左上角 : (10,65)
-
右上角 : (30,65)
-
右下角 : (30,55)
-
左下角 : (10,55)
回想一下,乘法的顺序很重要,缩放和*移运算符的顺序不能互换。
-
在 WC 的中心设置一个轻微旋转的 5x5 蓝色方块,并使用相机变换操作符进行绘制,
cameraMatrix
: -
现在画另外五个正方形,首先是中间的 2x2,在厕所的一个角上各画一个:
// Step F: Draw the blue square
// Center Blue, slightly rotated square
this.mBlueSq.getXform().setPosition(20, 60);
this.mBlueSq.getXform().setRotationInRad(0.2); // In Radians
this.mBlueSq.getXform().setSize(5, 5);
this.mBlueSq.draw(cameraMatrix);
// Step G: Draw the center and the corner squares
// center red square
this.mRedSq.getXform().setPosition(20, 60);
this.mRedSq.getXform().setSize(2, 2);
this.mRedSq.draw(cameraMatrix);
// top left
this.mTLSq.getXform().setPosition(10, 65);
this.mTLSq.draw(cameraMatrix);
// top right
this.mTRSq.getXform().setPosition(30, 65);
this.mTRSq.draw(cameraMatrix);
// bottom right
this.mBRSq.getXform().setPosition(30, 55);
this.mBRSq.draw(cameraMatrix);
// bottom left
this.mBLSq.getXform().setPosition(10, 55);
this.mBLSq.draw(cameraMatrix);
运行这个项目,观察四个角的不同颜色:左上角(mTLSq
)为红色,右上角(mTRSq
)为绿色,右下角(mBRSq
)为蓝色,左下角(mBLSq
)为深灰色。更改角方块的位置,以验证这些方块的中心位置位于 WC 的边界内,因此实际上只有四分之一的方块可见。例如,将mBlSq
设置为(12,57)以观察深灰色正方形实际上是四倍大小。该观察验证了视口/剪刀区域之外的正方形区域被 WebGL 剪裁。
虽然缺乏适当的抽象,但现在可以定义任何方便的 WC 系统和画布的任何矩形子区域来进行绘制。通过建模和相机转换,游戏程序员现在可以根据游戏的语义需求设计游戏解决方案,并忽略不相关的 WebGL NDC 绘图范围。然而,MyGame
类中的代码很复杂,可能会分散注意力。正如您到目前为止所看到的,重要的下一步是定义一个抽象来隐藏相机变换矩阵计算的细节。
照相机
相机变换允许定义一个 WC。在现实世界中,这类似于用照相机拍照。你相机取景器的中心就是 WC 的中心,通过取景器看到的世界的宽度和高度就是 WC 的尺寸。以此类推,拍摄照片的行为相当于计算 WC 中每个对象的绘图。最后,视口描述显示计算图像的位置。
相机对象项目
这个项目演示了如何抽象相机转换和视口,以隐藏矩阵计算和 WebGL 配置的细节。图 3-15 显示运行 Camera Objects 项目的输出;请注意,这个项目的输出与前一个项目的输出相同。这个项目的源代码在chapter3/3.5.camera_objects
文件夹中定义。
图 3-15
运行相机对象项目
该项目的目标如下:
-
定义
Camera
类来封装 WC 和 viewport 功能的定义 -
将
Camera
类集成到游戏引擎中 -
演示如何使用
Camera
对象
相机类
在前面的例子中,Camera
类必须封装由MyGame
构造函数中的缩放和转换操作符定义的功能。一个干净的、可重用的类设计应该用合适的 getter 和 setter 函数来完成。
-
通过在
src/engine
文件夹中创建一个新的源文件来定义游戏引擎中的Camera
类,并将该文件命名为camera.js
。 -
为
Camera
添加构造函数:
class Camera {
constructor(wcCenter, wcWidth, viewportArray) {
// WC and viewport position and size
this.mWCCenter = wcCenter;
this.mWCWidth = wcWidth;
this.mViewport = viewportArray; // [x, y, width, height]
// Camera transform operator
this.mCameraMatrix = mat4.create();
// background color
this.mBGColor = [0.8, 0.8, 0.8, 1]; // RGB and Alpha
}
... implementation to follow ...
}
Camera
定义了 WC 中心和宽度、视口、摄像机变换操作符和背景颜色。请注意以下几点:
-
在
Camera
类定义之外,定义访问viewportArray
的枚举索引: -
mWCCenter
是一个vec2
(vec2
在glMatrix
库中定义)。这是一个由两个元素组成的浮点数组。vec2
的第一个元素(索引位置 0)是 x 轴,第二个元素(索引位置 1)是 y 轴。 -
viewportArray
的四个元素依次是左下角的 x 和 y 位置以及视口的宽度和高度。视口的这种紧凑表示将实例变量的数量保持在最小,并有助于保持Camera
类的可管理性。 -
mWCWidth
是 WC 的宽度。为了保证 WC 和视口之间的纵横比匹配,WC 的高度总是根据视口和mWCWidth
的纵横比来计算。 -
mBgColor
是一个由四个浮点数组成的数组,代表一种颜色的红、绿、蓝和 alpha 分量。
const eViewport = Object.freeze({
eOrgX: 0,
eOrgY: 1,
eWidth: 2,
eHeight: 3
});
Note
枚举元素的名字以小写字母“e”开头,如eViewport
和eOrgX
。
-
定义基于视口纵横比计算 WC 高度的函数:
-
为实例变量添加 getters 和 setters:
getWCHeight() {
// viewportH/viewportW
let ratio = this.mViewport[eViewport.eHeight] /
this.mViewport[eViewport.eWidth];
return this.getWCWidth() * ratio;
}
- 创建一个函数来设置视口并计算该
Camera
的摄像机变换操作符:
setWCCenter(xPos, yPos) {
this.mWCCenter[0] = xPos;
this.mWCCenter[1] = yPos;
}
getWCCenter() { return this.mWCCenter; }
setWCWidth(width) { this.mWCWidth = width; }
setViewport(viewportArray) { this.mViewport = viewportArray; }
getViewport() { return this.mViewport; }
setBackgroundColor(newColor) { this.mBGColor = newColor; }
getBackgroundColor() { return this.mBGColor; }
// Initializes the camera to begin drawing
setViewAndCameraMatrix() {
let gl = glSys.get();
// Step A: Configure the viewport
... implementation to follow ...
// Step B: compute the Camera Matrix
... implementation to follow ...
}
注意,这个函数被称为setViewAndCameraMatrix()
,因为它配置 WebGL 来绘制所需的视口,并设置相机变换操作符。下面解释步骤 A 和 b 的细节。
- 在步骤 A 中配置视口的代码如下:
// Step A1: Set up the viewport: area on canvas to be drawn
gl.viewport(this.mViewport[0], // x of bottom-left of area to be drawn
this.mViewport[1], // y of bottom-left of area to be drawn
this.mViewport[2], // width of the area to be drawn
this.mViewport[3]); // height of the area to be drawn
// Step A2: set up the corresponding scissor area to limit the clear area
gl.scissor(this.mViewport[0], // x of bottom-left of area to be drawn
this.mViewport[1], // y of bottom-left of area to be drawn
this.mViewport[2], // width of the area to be drawn
this.mViewport[3]);// height of the area to be drawn
// Step A3: set the color to be clear
gl.clearColor(this.mBGColor[0], this.mBGColor[1],
this.mBGColor[2], this.mBGColor[3]);
// set the color to be cleared
// Step A4: enable scissor area, clear and then disable the scissor area
gl.enable(gl.SCISSOR_TEST);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.SCISSOR_TEST);
注意这些步骤与前一个例子的MyGame
中的视窗设置代码相似。唯一的区别是通过this
对实例变量的正确引用。
- 在步骤 B 中设置 Camera transform 操作符的代码如下:
// Step B: Compute the Camera Matrix
let center = this.getWCCenter();
// Step B1: after translation, scale to -1 to 1: 2x2 square at origin
mat4.scale(this.mCameraMatrix, mat4.create(),
vec3.fromValues(2.0 / this.getWCWidth(),
2.0 / this.getWCHeight(), 1.0));
// Step B2: first translate camera center to the origin
mat4.translate(this.mCameraMatrix, this.mCameraMatrix,
vec3.fromValues(-center[0], -center[1], 0));
同样,这段代码类似于上一个例子中的MyGame
构造函数。
-
定义一个函数来访问计算出的摄像机矩阵:
-
最后,记得导出新定义的
Camera
类:
getCameraMatrix() { return this.mCameraMatrix; }
export default Camera.
修改可渲染以支持摄影机类
必须修改Renderable
类的draw()
函数,以接收新定义的Camera
,从而访问计算出的摄像机矩阵:
draw(camera) {
let gl = glSys.get();
this.mShader.activate(this.mColor, this.mXform.getTRSMatrix(),
camera.getCameraMatrix());
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
修改引擎访问文件以导出相机
保持引擎访问文件index.js
为最新是很重要的,以便游戏开发者可以访问新定义的Camera
类:
-
编辑
index.js
;从新定义的camera.js
文件导入: -
导出
Camera
供客户端访问:
// general utilities
import Camera from "./camera.js";
import Transform from "./transform.js";
import Renderable from "./renderable.js";
export default {
// Util classes
Camera, Transform, Renderable,
// functions
init, clearCanvas
}
测试摄像机
正确定义了Camera
类后,从my_game.js
开始测试就很简单了:
-
编辑
my_game.js
;在步骤 A 中初始化游戏引擎之后,创建一个Camera
对象的实例,其设置定义了步骤 B 中的前一个项目的 WC 和视口: -
继续创建六个
Renderable
对象,并在步骤 C 和 D 中清除画布:
class MyGame {
constructor(htmlCanvasID) {
// Step A: Initialize the game engine
engine.init(htmlCanvasID);
// Step B: Setup the camera
this.mCamera = new engine.Camera(
vec2.fromValues(20, 60), // center of the WC
20, // width of WC
[20, 40, 600, 300] // viewport:orgX, orgY, W, H
);
... implementation to follow ...
}
- 现在,调用中的
Camera
对象的setViewAndCameraMatrix()
函数来配置 WebGL 视口并在步骤 E 中计算相机矩阵,在步骤 F 和 g 中使用Camera
对象绘制所有的Renderable
// Step C: Create the Renderable objects:
this.mBlueSq = new engine.Renderable();
this.mBlueSq.setColor([0.25, 0.25, 0.95, 1]);
this.mRedSq = new engine.Renderable();
this.mRedSq.setColor([1, 0.25, 0.25, 1]);
this.mTLSq = new engine.Renderable();
this.mTLSq.setColor([0.9, 0.1, 0.1, 1]);
this.mTRSq = new engine.Renderable();
this.mTRSq.setColor([0.1, 0.9, 0.1, 1]);
this.mBRSq = new engine.Renderable();
this.mBRSq.setColor([0.1, 0.1, 0.9, 1]);
this.mBLSq = new engine.Renderable();
this.mBLSq.setColor([0.1, 0.1, 0.1, 1]);
// Step D: Clear the canvas
engine.clearCanvas([0.9, 0.9, 0.9, 1]); // Clear the canvas
// Step E: Starts the drawing by activating the camera
this.mCamera.setViewAndCameraMatrix();
// Step F: Draw the blue square
// Center Blue, slightly rotated square
this.mBlueSq.getXform().setPosition(20, 60);
this.mBlueSq.getXform().setRotationInRad(0.2); // In Radians
this.mBlueSq.getXform().setSize(5, 5);
this.mBlueSq.draw(this.mCamera);
// Step G: Draw the center and the corner squares
// center red square
this.mRedSq.getXform().setPosition(20, 60);
this.mRedSq.getXform().setSize(2, 2);
this.mRedSq.draw(this.mCamera);
// top left
this.mTLSq.getXform().setPosition(10, 65);
this.mTLSq.draw(this.mCamera);
// top right
this.mTRSq.getXform().setPosition(30, 65);
this.mTRSq.draw(this.mCamera);
// bottom right
this.mBRSq.getXform().setPosition(30, 55);
this.mBRSq.draw(this.mCamera);
// bottom left
this.mBLSq.getXform().setPosition(10, 55);
this.mBLSq.draw(this.mCamera);
mCamera
对象被传递给Renderable
对象的draw()
函数,这样摄像机变换矩阵操作符可以被检索并用于激活着色器。
摘要
在这一章中,你学习了如何创建一个支持多种对象绘制的系统。该系统由三部分组成:对象、每个对象的细节以及对象在浏览器画布上的显示。对象由Renderable
封装,它使用一个Transform
来捕捉它的细节——位置、大小和旋转。显示对象的细节由Camera
定义,其中特定位置的对象可以显示在画布上期望的子区域。
您还了解了对象都是相对于世界空间或 WC(一种方便的坐标系)绘制的。基于坐标变换为场景合成定义 WC。最后,Camera 变换用于选择在浏览器中的画布上实际显示 WC 的哪一部分。这可以通过定义一个可由Camera
查看的区域并使用 WebGL 提供的视窗功能来实现。
当您构建绘图系统时,游戏引擎源代码结构一直被重构为抽象和封装的组件。这样,源代码结构继续支持进一步的扩展,包括下一章将讨论的附加功能。
四、实现视频游戏的通用组件
完成本章后,您将能够
-
控制
Renderable
对象的位置、大小和旋转,以构建复杂的运动和动画 -
接收来自玩家的键盘输入以控制和动画制作
Renderable
对象 -
使用外部资产的异步加载和卸载
-
从场景文件中定义、加载和执行一个简单的游戏关卡
-
通过加载新场景来更改游戏级别
-
使用声音剪辑作为背景音乐和音频提示
介绍
在前面的章节中,构建了一个骨骼游戏引擎来支持基本的绘图操作。绘图是构建游戏引擎的第一步,因为它允许您在继续扩展游戏引擎功能的同时观察输出。在这一章中,两个重要的机制,交互性和资源支持,将被检查并添加到游戏引擎中。交互性允许引擎接收和解释玩家输入,而资源支持指的是使用外部文件(如 GLSL 着色器源代码文件、音频剪辑和图像)的功能。
本章首先向您介绍游戏循环,这是一个在几乎所有视频游戏中创造实时互动和即时感的关键组件。基于游戏循环基础,将通过集成相应的 HTML5 功能来支持玩家键盘输入。将从头开始构建资源管理基础设施,以支持外部文件的有效加载、存储、检索和利用。用于处理外部文本文件(例如,GLSL 着色器源代码文件)和音频剪辑的功能将与相应的示例项目集成。此外,游戏场景架构将被派生以支持处理多个场景和场景转换的能力,包括在外部场景文件中定义的场景。本章结束时,您的游戏引擎将支持玩家通过键盘进行互动,能够提供音频反馈,并能够在不同的游戏关卡之间转换,包括从外部文件加载关卡。
游戏循环
任何视频游戏最基本的操作之一是支持玩家的输入和图形游戏元素之间看似即时的交互。实际上,这些交互被实现为一个连续运行的循环,接收和处理玩家输入,更新游戏状态,并呈现游戏。这个不断运行的循环被称为游戏循环。
为了传达适当的即时感,游戏循环的每个周期都必须在一般人的反应时间内完成。这通常被称为实时,即人类视觉上无法察觉的太短的时间。通常,当游戏循环以高于每秒 40–60 个循环的速度运行时,可以实现实时。由于在每个游戏循环周期中通常有一个绘制操作,因此该周期的速率也称为每秒帧数(FPS)或帧速率。FPS 为 60 是一个很好的性能目标。也就是说,你的游戏引擎必须在 1/60 秒内接收玩家输入,更新游戏世界,然后绘制游戏世界全部完成!
游戏循环本身,包括实现细节,是一个游戏最基本的控制结构。以保持实时性能为主要目标,游戏循环操作的细节与游戏引擎的其余部分无关。因此,游戏循环的实现应该紧密封装在游戏引擎的核心中,其详细操作对其他游戏元素隐藏起来。
典型的游戏循环实现
游戏循环是一种机制,通过它逻辑和绘图被连续执行。一个简单的游戏循环包括绘制所有对象、处理玩家输入以及更新这些对象的状态,如下面的伪代码所示:
initialize();
while(game running) {
draw();
input();
update();
}
如前所述,需要 60 的 FPS 来保持实时交互性。当游戏复杂性增加时,可能出现的一个问题是,有时一个循环可能需要 1/60 秒以上的时间才能完成,导致游戏以降低的帧速率运行。当这种情况发生时,整个游戏将会变慢。一个常见的解决方案是将一些操作优先于其他操作。也就是说,引擎可以被设计成将游戏循环固定在完成引擎认为更重要的操作上,而跳过其他操作。由于正确的输入和更新是游戏正常运行所必需的,所以必要时通常会跳过绘制操作。这被称为跳帧,下面的伪代码说明了一个这样的实现:
elapsedTime = now;
previousLoop = now;
while(game running) {
elapsedTime += now - previousLoop;
previousLoop = now;
draw();
input();
while( elapsedTime >= UPDATE_TIME_RATE ) {
update();
elapsedTime -= UPDATE_TIME_RATE;
}
}
在前面的伪代码清单中,UPDATE_TIME_RATE
是所需的实时更新速率。当游戏循环周期之间经过的时间大于UPDATE_TIME_RATE
时,update()
将被调用,直到它赶上为止。这意味着当游戏循环运行太慢时,基本上会跳过draw()
操作。当这种情况发生时,整个游戏看起来运行缓慢,游戏输入响应滞后,画面被跳过。但是,游戏逻辑将继续正常运行。
注意,包含update()
函数调用的while
循环模拟了UPDATE_TIME_RATE
的固定更新时间步长。这种固定的时间步长更新允许在维持确定性游戏状态中的直接实现。这是一个重要的组成部分,以确保您的游戏引擎的功能,无论运行最佳或缓慢。
为了确保只关注对核心游戏循环的绘制和更新操作的理解,输入将被忽略,直到下一个项目。
游戏循环项目
这个项目演示了如何将一个游戏循环整合到你的游戏引擎中,并通过绘制和更新Renderable
对象来支持实时动画。你可以在图 4-1 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.1.game_loop
文件夹中定义。
图 4-1
运行游戏循环项目
该项目的目标如下:
-
为了理解游戏循环的内部操作
-
实现和封装游戏循环的操作
-
通过不断绘制和更新来获得创建动画的经验
实现游戏循环组件
游戏循环组件是游戏引擎功能的核心,因此其位置应该与vertex_buffer
相似,作为一个在src/engine/core
文件夹中定义的文件:
-
在
src/engine/core
文件夹中为循环模块创建一个新文件,并将该文件命名为loop.js
。 -
定义以下实例变量来跟踪帧速率、每帧的处理时间(毫秒)、游戏循环的当前运行状态以及对当前场景的引用,如下所示:
"use strict"
const kUPS = 60; // Updates per second
const kMPF = 1000 / kUPS; // Milliseconds per update.
// Variables for timing gameloop.
let mPrevTime;
let mLagTime;
// The current loop state (running or should stop)
let mLoopRunning = false;
let mCurrentScene = null;
let mFrameID = -1;
请注意,kUPS
是每秒的更新数,类似于我们讨论的FPS
,它被设置为每秒 60 或 60 次更新。每次更新可用的时间仅为 1/60 秒。因为一秒钟有 1000 毫秒,所以每次更新的可用时间是 1000 * (1/60),即kMPF
。
Note
当游戏运行在最佳状态时,帧绘制和更新都保持在相同的速率;FPS
和kUPS
可以互换考虑。然而,当延迟发生时,loop
跳过帧绘制并优先更新。在这种情况下,FPS
将减少,而kUPS
将保持不变。
- 添加运行核心循环的函数,如下所示:
function loopOnce() {
if (mLoopRunning) {
// Step A: set up for next call to LoopOnce
mFrameID = requestAnimationFrame(loopOnce);
// Step B: now let's draw
// draw() MUST be called before update()
// as update() may stop the loop!
mCurrentScene.draw();
// Step C: compute time elapsed since last loopOnce was executed
let currentTime = performance.now();
let elapsedTime = currentTime - mPrevTime;
mPrevTime = currentTime;
mLagTime += elapsedTime;
// Step D: update the game the appropriate number of times.
// Update only every kMPF (1/60 of a second)
// If lag larger then update frames, update until caught up.
while ((mLagTime >= kMPF) && mLoopRunning) {
mCurrentScene.update();
mLagTime -= kMPF;
}
}
}
Note
performance.now()
是一个 JavaScript 函数,返回以毫秒为单位的时间戳。
请注意前面检查的伪代码与loopOnce()
函数的步骤 B、C 和 D 之间的相似性,即步骤 B 中场景或游戏的绘制,步骤 C 中自上次更新以来经过时间的计算,以及如果引擎落后,更新的优先级。
主要区别在于,最外层的 while 循环是基于步骤 a 中的 HTML5 requestAnimationFrame()
函数调用实现的。requestAnimationFrame()
函数将以大约每秒 60 次的速度调用作为其参数传入的函数指针。在这种情况下,loopOnce()
功能将以大约每秒 60 次的速度被连续调用。注意,每次调用requestAnimationFrame()
函数都会导致相应的loopOnce()
函数执行一次,因此只绘制一次。但是,如果系统滞后,在这一帧中可能会发生多次更新。
Note
requestAnimationFrame()
函数是一个 HTML5 实用程序,由托管游戏的浏览器提供。该函数的精确行为取决于浏览器的实现。
现在,步骤 D 中的while
循环的mLoopRunning
条件是一个冗余检查。当update()
可以调用stop()
来停止循环时,这个条件在后面的部分会变得很重要(例如,对于关卡转换或游戏结束)。
-
声明一个函数来
start
游戏循环。这个函数初始化游戏或场景、帧时间变量和循环运行标志,然后用loopOnce
函数作为参数调用第一个requestAnimationFrame()
来开始游戏循环。 -
声明一个函数来
stop
游戏循环。该功能通过将mLoopRunning
设置为false
来停止循环,并取消最后一个请求的动画帧。
function start(scene) {
if (mLoopRunning) {
throw new Error("loop already running")
}
mCurrentScene = scene;
mCurrentScene.init();
mPrevTime = performance.now();
mLagTime = 0.0;
mLoopRunning = true;
mFrameID = requestAnimationFrame(loopOnce);
}
- 最后,记住将期望的功能
export
给游戏引擎的其余部分,在这种情况下,只有start
和stop
功能:
function stop() {
mLoopRunning = false;
// make sure no more animation frames
cancelAnimationFrame(mFrameID);
}
export {start, stop}
使用游戏循环
为了测试游戏循环的实现,你的游戏类现在必须实现draw()
、update()
和init()
函数。这是因为为了协调游戏的开始和持续运行,这些函数是从游戏循环的核心调用的——从loop.start()
调用init()
函数,而从loop.loopOnce()
调用draw()
和update()
函数。
-
编辑您的
my_game.js
文件,通过从模块导入来提供对循环的访问。允许游戏开发者访问游戏循环模块是一个临时措施,将在后面的章节中得到纠正。 -
用以下内容替换
MyGame
构造函数:
// Accessing engine internal is not ideal,
// this must be resolved! (later)
import * as loop from "../engine/core/loop.js";
- 添加一个初始化函数来设置一个摄像机和两个
Renderable
对象:
constructor() {
// variables for the squares
this.mWhiteSq = null; // these are the Renderable objects
this.mRedSq = null;
// The camera to view the scene
this.mCamera = null;
}
- 像以前一样通过清除画布、设置相机并绘制每个方块来绘制场景:
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(20, 60), // position of the camera
20, // width of camera
[20, 40, 600, 300] // viewport (orgX, orgY, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
// Step B: Create the Renderable objects:
this.mWhiteSq = new engine.Renderable();
this.mWhiteSq.setColor([1, 1, 1, 1]);
this.mRedSq = new engine.Renderable();
this.mRedSq.setColor([1, 0, 0, 1]);
// Step C: Init the white Renderable: centered, 5x5, rotated
this.mWhiteSq.getXform().setPosition(20, 60);
this.mWhiteSq.getXform().setRotationInRad(0.2); // In Radians
this.mWhiteSq.getXform().setSize(5, 5);
// Step D: Initialize the red Renderable object: centered 2x2
this.mRedSq.getXform().setPosition(20, 60);
this.mRedSq.getXform().setSize(2, 2);
}
- 添加一个
update()
函数来激活一个移动的白色方块和一个跳动的红色方块:
draw() {
// Step A: clear the canvas
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray
// Step B: Activate the drawing Camera
this.mCamera.setViewAndCameraMatrix();
// Step C: Activate the white shader to draw
this.mWhiteSq.draw(this.mCamera);
// Step D: Activate the red shader to draw
this.mRedSq.draw(this.mCamera);
}
update() {
// Simple game: move the white square and pulse the red
let whiteXform = this.mWhiteSq.getXform();
let deltaX = 0.05;
// Step A: Rotate the white square
if (whiteXform.getXPos() > 30) // the right-bound of the window
whiteXform.setPosition(10, 60);
whiteXform.incXPosBy(deltaX);
whiteXform.incRotationByDegree(1);
// Step B: pulse the red square
let redXform = this.mRedSq.getXform();
if (redXform.getWidth() > 5)
redXform.setSize(2, 2);
redXform.incSizeBy(0.05);
}
回想一下,每秒钟调用update()
函数大约 60 次,每次都发生以下情况:
- 从
window.onload
功能启动游戏loop
。注意,对MyGame
实例的引用被传递给了loop
。
-
白色正方形的步骤 A:将旋转增加 1 度,将 x 位置增加 0.05,如果得到的 x 位置大于 30,则重置为 10。
-
红色方块的步骤 B:将大小增加 0.05,如果结果大小大于 5,则将其重置为 2。
-
由于前面的操作以每秒大约 60 次的速度连续执行,您可以预期看到以下内容:
-
向右移动时旋转的白色正方形,到达右边界时绕到左边界
-
一个红色正方形,尺寸增加,当尺寸达到 5 时减小到 2,因此看起来像是在跳动
-
window.onload = function () {
engine.init("GLCanvas");
let myGame = new MyGame();
// new begins the game
loop.start(myGame);
}
现在,您可以运行项目来观察向右移动、旋转的白色正方形和跳动的红色正方形。您可以通过改变incXPosBy()
、incRotationByDegree()
和incSizeBy()
功能的相应值来控制移动、旋转和脉冲的速率。在这些情况下,位置值、旋转值和大小值在固定的时间间隔内以恒定的量变化。实际上,这些函数的参数是变化率,或速度,incXPosBy(0.05)
,是 0.05 单位/1/60 秒或 3 单位/秒的向右速度。在此项目中,世界的宽度为 20 个单位,白色方块以每秒 3 个单位的速度移动,您可以验证白色方块从左边界移动到右边界需要 6 秒多一点的时间。
注意,在loop
模块的核心中,requestAnimationFrame()
函数完全有可能在单个kMPF
间隔内多次调用loopOnce()
函数。当这种情况发生时,draw()
函数将被多次调用,而没有任何update()
函数调用。这样,游戏循环可以多次结束绘制相同的游戏状态。请参考以下参考资料,讨论如何在draw()
函数中支持外推,以利用高效的游戏循环:
-
http://gameprogrammingpatterns.com/game-loop.html#play-catch-up
-
http://gafferongames.com/game-physics/fix-your-timestep/
为了清楚地描述游戏引擎的每个组件,并说明这些组件是如何交互的,本书不支持draw()
函数的外推。
键盘输入
很明显,对接收玩家输入的适当支持对于交互式视频游戏是很重要的。对于典型的个人计算设备,如 PC 或 Mac,两种常见的输入设备是键盘和鼠标。虽然键盘输入是以字符流的形式接收的,但是鼠标输入是与位置信息打包在一起的,并且与摄像机视图相关。因此,在引擎开发的这个阶段,键盘输入更容易支持。本节将介绍键盘支持并将其集成到您的游戏引擎中。鼠标输入将在第七章的鼠标输入项目中考察,在同一游戏支持多个摄像头的覆盖之后。
键盘支持项目
该项目检查键盘输入支持,并将该功能集成到游戏引擎中。这个项目中游戏对象的位置、旋转和大小都在你的输入控制之下。你可以在图 4-2 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.2.keyboard_support
文件夹中定义。
图 4-2
运行键盘支持项目
该项目的控制措施如下:
-
右箭头键:将白色方块向右移动,并将其绕到游戏窗口的左侧
-
向上箭头键:旋转白色方块
-
向下箭头键:增加红色方块的大小,然后在阈值处重新设置大小
该项目的目标如下:
-
实现接收键盘输入的引擎组件
-
理解按键状态(如果按键被释放或按下)和按键事件(当按键状态改变时)之间的区别
-
了解如何在游戏循环中集成输入组件
向引擎添加输入组件
回想一下,循环组件是游戏引擎核心的一部分,不应该被客户端游戏开发者访问。相比之下,定义良好的输入模块应该支持客户端游戏开发人员查询键盘状态,而不受任何细节的干扰。因此,输入模块将被定义在src/engine
文件夹中。
-
在
src/engine
文件夹中创建一个新文件,并将其命名为input.js
。 -
定义一个 JavaScript 字典来捕获关键代码映射:
"use strict"
// Key code constants
const keys = {
// arrows
Left: 37,
Up: 38,
Right: 39,
Down: 40,
// space bar
Space: 32,
// numbers
Zero: 48,
One: 49,
Two: 50,
Three: 51,
Four: 52,
Five : 53,
Six : 54,
Seven : 55,
Eight : 56,
Nine : 57,
// Alphabets
A : 65,
D : 68,
E : 69,
F : 70,
G : 71,
I : 73,
J : 74,
K : 75,
L : 76,
Q : 81,
R : 82,
S : 83,
W : 87,
LastKeyCode: 222
}
键码是代表每个键盘字符的唯一数字。请注意,最多有 222 个唯一键。在清单中,字典中只定义了一小部分与这个项目相关的键。
Note
字母的键码是连续的,从 A 的 65 开始,到 z 的 90 结束。你可以随意为你自己的游戏引擎添加任何字符。有关键码的完整列表,请参见 www.cambiaresearch.com/articles/15/javascript-char-codes-key-codes
。
- 创建数组实例变量来跟踪每个键的状态:
// Previous key state
let mKeyPreviousState = []; // a new array
// The pressed keys.
let mIsKeyPressed = [];
// Click events: once an event is set, it will remain there until polled
let mIsKeyClicked = [];
这三个数组都将每个键的状态定义为布尔值。mKeyPreviousState
记录前一更新周期的密钥状态,mIsKeyPressed
记录密钥的当前状态。当按下相应的键盘键时,这两个数组的键码条目为真,否则为假。mIsKeyClicked
数组捕获按键点击事件。只有当相应的键盘键在两个连续的更新周期中从释放变为按下时,该数组的键码条目才为真。
需要注意的是,KeyPress
是一个键的状态,而KeyClicked
是一个事件。例如,如果玩家在释放键之前按下键一秒钟,那么 A 的整个第二KeyPress
的持续时间为真,而 A 的KeyClick
只为真一次——该键被按下后的更新周期。
- 定义函数来捕捉实际的键盘状态变化:
// Event handler functions
function onKeyDown(event) {
mIsKeyPressed[event.keyCode] = true;
}
function onKeyUp(event) {
mIsKeyPressed[event.keyCode] = false;
}
当调用这些函数时,参数中的键码用于记录相应的键盘状态变化。预计这些函数的调用方将在参数中传递适当的关键代码。
-
添加一个函数来初始化所有按键状态,并向浏览器注册按键事件处理程序。
window.addEventListener()
函数向浏览器注册onKeyUp/Down()
事件处理程序,这样当玩家按下或释放键盘上的键时,相应的函数将被调用。 -
添加一个
update()
函数来派生按键点击事件。update()
功能使用mIsKeyPressed
和mKeyPreviousState
来确定是否发生了按键事件。
function init() {
let i;
for (i = 0; i < keys.LastKeyCode; i++) {
mIsKeyPressed[i] = false;
mKeyPreviousState[i] = false;
mIsKeyClicked[i] = false;
}
// register handlers
window.addEventListener('keyup', onKeyUp);
window.addEventListener('keydown', onKeyDown);
}
- 添加查询当前键盘状态的公共函数,以支持客户端游戏开发者:
function update() {
let i;
for (i = 0; i < keys.LastKeyCode; i++) {
mIsKeyClicked[i] = (!mKeyPreviousState[i]) && mIsKeyPressed[i];
mKeyPreviousState[i] = mIsKeyPressed[i];
}
}
- 最后,导出公共函数和关键常量:
// Function for GameEngine programmer to test if a key is pressed down
function isKeyPressed(keyCode) {
return mIsKeyPressed[keyCode];
}
function isKeyClicked(keyCode) {
return mIsKeyClicked[keyCode];
}
export {keys, init,
update,
isKeyClicked,
isKeyPressed
}
修改引擎以支持键盘输入
为了正确支持输入,在游戏循环开始之前,引擎必须初始化mIsKeyPressed
、mIsKeyClicked
和mKeyPreviousState
数组。为了正确地捕捉玩家的动作,在游戏过程中,从游戏循环的核心,这些数组必须相应地更新。
-
输入状态初始化:通过导入
input.js
模块来修改index.js
,将输入的初始化添加到引擎init()
函数中,并将input
模块添加到导出列表中,以允许客户端游戏开发者访问。 -
为了准确地捕捉键盘状态变化,输入组件必须与游戏循环的核心集成在一起。将
input
的update()
功能加入到核心游戏loop
中,在loop.js
中加入以下几行。注意,代码的其余部分是相同的。
import * as input from "./input.js";
function init(htmlCanvasID) {
glSys.init(htmlCanvasID);
vertexBuffer.init();
shaderResources.init();
input.init();
}
export default {
// input support
input,
// Util classes
Camera, Transform, Renderable,
// functions
init, clearCanvas
}
import * as input from "../input.js";
function loopOnce() {
if (mLoopRunning) {
... identical to previous code ...
// Step D: update the game the appropriate number of times.
// Update only every kMPF (1/60 of a second)
// If lag larger then update frames, update until caught up.
while ((mLagTime >= kMPF) && mLoopRunning) {
input.update();
mCurrentScene.update();
mLagTime -= kMPF;
}
}
}
测试键盘输入
您可以通过修改您的MyGame
类中的Renderable
对象来测试输入功能。用以下代码替换MyGame update()
功能中的代码:
update() {
// Simple game: move the white square and pulse the red
let whiteXform = this.mWhiteSq.getXform();
let deltaX = 0.05;
// Step A: test for white square movement
if (engine.input.isKeyPressed(engine.input.keys.Right)) {
if (whiteXform.getXPos() > 30) { // right-bound of the window
whiteXform.setPosition(10, 60);
}
whiteXform.incXPosBy(deltaX);
}
// Step B: test for white square rotation
if (engine.input.isKeyClicked(engine.input.keys.Up)) {
whiteXform.incRotationByDegree(1);
}
let redXform = this.mRedSq.getXform();
// Step C: test for pulsing the red square
if (engine.input.isKeyPressed(engine.input.keys.Down)) {
if (redXform.getWidth() > 5) {
redXform.setSize(2, 2);
}
redXform.incSizeBy(0.05);
}
}
在前面的代码中,步骤 A 确保按住右箭头键将白色方块向右移动。步骤 B 检查上箭头键事件的按下和释放。当检测到这样的事件时,旋转白色方块。请注意,按住向上箭头键不会连续生成按键事件,因此不会导致白色方块连续旋转。步骤 C 测试按住向下箭头键以使红色方块跳动。
您可以运行该项目,并包括用于操纵正方形的附加控件。例如,支持 WASD 键来控制红色方块的位置。请再次注意,通过增加/减少位置变化量,您可以有效地控制对象的移动速度。
Note
术语“ WASD 键”用来指流行的游戏控制键绑定:W 键向上移动,A 键向左,S 键向下,D 键向右。
资源管理和异步加载
视频游戏通常利用大量艺术资产或资源,包括音频剪辑和图像。支持一个游戏所需的资源可能很大。此外,重要的是保持资源和实际游戏之间的独立性,以便它们可以独立地更新,例如,改变背景音频而不改变游戏本身。由于这些原因,游戏资源通常存储在外部的系统硬盘或网络服务器上。由于存储在游戏外部,资源有时被称为外部资源或资产。
游戏开始后,必须显式加载外部资源。为了有效地利用内存,游戏应该根据需要动态地加载和卸载资源。然而,加载外部资源可能涉及输入/输出设备操作或网络数据包延迟,因此可能是时间密集型的,并可能影响实时交互性。由于这些原因,在游戏的任何情况下,只有一部分资源被保存在内存中,其中加载操作被有策略地执行以避免中断游戏。在大多数情况下,每个级别所需的资源在该级别的游戏过程中都保存在内存中。使用这种方法,外部资源加载可以发生在等级转换期间,此时玩家期望一个新的游戏环境,并且更可能容忍加载的轻微延迟。
一旦加载,资源必须易于访问以支持交互性。高效和有效的资源管理对任何游戏引擎都是至关重要的。请注意资源管理和资源的实际所有权之间的明显区别,资源管理是游戏引擎的责任。例如,游戏引擎必须支持游戏背景音乐的有效加载和播放,并且是游戏(或游戏引擎的客户端)实际拥有并提供背景音乐的音频文件。当实现对外部资源管理的支持时,重要的是要记住实际的资源不是游戏引擎的一部分。
此时,您构建的游戏引擎只处理一种类型的资源——GLSL 着色器文件。回想一下,SimpleShader
对象在其构造函数中加载并编译了simple_vs.glsl
和simple_fs.glsl
文件。到目前为止,着色器文件加载已经通过同步XMLHttpRequest.open()
完成。这种同步加载是低效资源管理的一个示例,因为当浏览器试图打开和加载着色器文件时,不会发生任何操作。一种有效的替代方法是发出异步加载命令,并允许在打开和加载文件的同时继续其他操作。
本节构建了一个支持异步加载和高效访问加载资源的基础设施。基于这一基础设施,在接下来的几个项目中,游戏引擎将扩展到支持场景转换期间的批量资源加载。
资源贴图和着色器加载器项目
该项目指导您开发resource_map
组件,这是一个用于资源管理的基础模块,并演示了如何使用该模块异步加载着色器文件。你可以在图 4-3 中看到这个项目运行的例子。这个项目看起来与前一个项目相同,唯一的区别是如何加载 GLSL 着色器。这个项目的源代码在chapter4/4.3.resource_map_and_shader_loader
文件夹中定义。
图 4-3
运行资源贴图和着色器加载器项目
该项目的控制与前一个项目相同,如下所示:
-
右箭头键:将白色方块向右移动,并将其绕到游戏窗口的左侧
-
向上箭头键:旋转白色方块
-
向下箭头键:增加红色方块的大小,然后在阈值处重新设置大小
该项目的目标如下:
-
要理解异步加载的处理
-
构建支持未来资源加载和访问的基础设施
-
通过加载 GLSL 着色器文件来体验异步资源加载
Note
关于异步 JavaScript 操作的更多信息,可以参考网上很多优秀的资源,例如, https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous
。
将资源映射组件添加到引擎中
resource_map
引擎组件管理资源加载、存储和资源加载后的检索。这些操作是游戏引擎内部的,不应该被游戏引擎客户端访问。与所有核心引擎组件一样,例如游戏循环,源代码文件是在src/engine/core
文件夹中创建的。详情如下。
-
在
src/engine/core
文件夹中创建一个新文件,并将其命名为resource_map.js
。 -
定义
MapEntry
类来支持加载资源的引用计数。引用计数对于避免资源的多次加载或过早卸载至关重要。 -
定义一个键值对映射
mMap
,用于存储和检索资源,定义一个数组mOutstandingPromises
,用于捕获所有未完成的异步加载操作:
class MapEntry {
constructor(data) {
this.mData = data;
this.mRefCount = 1;
}
decRef() { this.mRefCount--; }
incRef() { this. mRefCount++; }
set(data) { this.mData = data;}
data() { return this.mData; }
canRemove() { return (this.mRefCount == 0); }
}
let mMap = new Map();
let mOutstandingPromises = [];
Note
JavaScript Map
对象保存一组键值对。
-
定义用于查询资源是否存在、检索和设置资源的函数。请注意,如参数
path
的变量名所示,外部资源文件的完整路径将被用作访问相应资源的键,例如,使用src/glsl_shaders/simple_vs.glsl
文件的路径作为访问文件内容的键。 -
定义函数来指示已请求加载,增加已加载资源的引用计数,并正确卸载资源。由于加载操作的异步性质,加载请求将导致空的
MapEntry
,当加载操作在将来某个时候完成时,该空的MapEntry
将被更新。请注意,每个卸载请求都会减少引用计数,并且可能会也可能不会导致资源被卸载。
function has(path) { return mMap.has(path) }
function get(path) {
if (!has(path)) {
throw new Error("Error [" + path + "]: not loaded");
}
return mMap.get(path).data();
}
function set(key, value) { mMap.get(key).set(value); }
- 定义一个函数,将正在进行的异步加载操作追加到
mOutstandingPromises
数组中
function loadRequested(path) {
mMap.set(path, new MapEntry(null));
}
function incRef(path) {
mMap.get(path).incRef();
}
function unload(path) {
let entry = mMap.get(path);
entry.decRef();
if (entry.canRemove())
mMap.delete(path)
return entry.canRemove();
}
- 定义一个加载函数,
loadDecodeParse()
。如果资源已经加载,则相应的引用计数将递增。否则,该函数首先发出一个loadRequest()
,在mMap
中创建一个空的MapEntry
。然后,该函数创建一个 HTML5fetch
承诺,使用资源的路径作为关键字,异步获取外部资源,解码网络打包,将结果解析为适当的格式,并将结果更新到创建的MapEntry
中。这个创建的承诺然后被推入mOutstandingPromises
数组。
function pushPromise(p) { mOutstandingPromises.push(p); }
// generic loading function,
// Step 1: fetch from server
// Step 2: decodeResource on the loaded package
// Step 3: parseResource on the decodedResource
// Step 4: store result into the map
// Push the promised operation into an array
function loadDecodeParse(path, decodeResource, parseResource) {
let fetchPromise = null;
if (!has(path)) {
loadRequested(path);
fetchPromise = fetch(path)
.then(res => decodeResource(res) )
.then(data => parseResource(data) )
.then(data => { return set(path, data) } )
.catch(err => { throw err });
pushPromise(fetchPromise);
} else {
incRef(path); // increase reference count
}
return fetchPromise;
}
注意,解码和解析函数是作为参数传入的,因此依赖于正在获取的实际资源类型。例如,简单文本、XML(可扩展标记语言)格式的文本、音频剪辑和图像的解码和解析都有不同的要求。定义这些函数是实际资源加载器的责任。
HTML5 fetch()
函数返回一个 JavaScript promise
对象。典型的 JavaScript promise
对象包含将在未来完成的操作。当操作完成时,promise
是fulfilled
。在这种情况下,当path
被正确提取、解码、解析并更新到相应的MapEntry
中时,fetchPromise
为fulfilled
。这个promise
被保存在mOutstandingPromises
数组中。请注意,在loadDecodeParse()
功能结束时,异步fetch()
加载操作被发出并正在进行,但不保证完成。通过这种方式,mOutstandingPromises
是一系列正在进行的、未实现的或未完成的承诺。
- 定义一个 JavaScript
async
函数来阻止执行,并等待所有未完成的承诺得到履行,或者等待所有正在进行的异步加载操作完成:
// will block, wait for all outstanding promises complete
// before continue
async function waitOnPromises() {
await Promise.all(mOutstandingPromises);
mOutstandingPromises = []; // remove all
}
Note
JavaScript async
/ await
关键字是成对的,只有async
函数可以await
用于promise
。await
语句阻塞并将执行返回给async
函数的调用者。当正在等待的promise
完成时,执行将继续到async
功能结束。
- 最后,将功能导出到游戏引擎的其余部分:
export {has, get, set,
loadRequested, incRef, loadDecodeParse,
unload,
pushPromise, waitOnPromises}
注意,尽管特定于存储的功能——查询、获取和设置——被很好地定义,但是resource_map
实际上不能加载任何特定的资源。这个模块是为资源类型特定的模块设计的,在这些模块中可以正确定义解码和解析功能。在下一小节中,将定义一个文本资源加载器来演示这一思想。
定义文本资源模块
本节将定义一个text
模块,它利用resource_map
模块异步加载您的文本文件。这个模块是如何利用resource_map
工具的一个很好的例子,它允许你替换 GLSL 着色器文件的同步加载。用异步加载支持取代同步是对游戏引擎的重大升级。
-
在
src/engine/
中新建一个文件夹,命名为resources
。创建这个新文件夹是为了预期对许多资源类型的必要支持,并维护一个干净的源代码组织。 -
在
src/engine/resources
文件夹中创建一个新文件,并将其命名为text.js
。 -
导入核心资源管理并重用
resource_map
中的相关功能: -
为
loadDecodeParse()
定义文本解码和解析功能。注意,对解析加载的文本没有要求,因此,文本解析函数不执行任何有用的操作。
"use strict"
import * as map from "../core/resource_map.js";
// functions from resource_map
let unload = map.unload;
let has = map.has;
let get = map.get;
- 定义
load()
函数调用resource_map loadDecodeParse()
函数触发异步fetch()
操作:
function decodeText(data) {
return data.text();
}
function parseText(text) {
return text;
}
- 导出功能以提供对游戏引擎其余部分的访问:
function load(path) {
return map.loadDecodeParse(path, decodeText, parseText);
}
- 最后,记得更新在
index.js
中为客户端定义的功能:
export {has, get, load, unload}
import * as text from "./resources/text.js";
... identical to previous code ...
export default {
// resource support
text,
... identical to previous code ...
}
异步加载着色器
text
资源模块现在可以用来帮助将着色器文件作为纯文本文件异步加载。由于无法预测异步加载操作何时完成,因此在需要资源之前发出加载命令并确保加载操作在继续检索资源之前完成是很重要的。
修改着色器资源以获得异步支持
为了避免同步加载 GLSL 着色器文件,必须在创建SimpleShader
对象之前加载这些文件。回想一下,SimpleShader
对象的单个实例是在shader_resources
模块中创建的,并在所有的Renderable
中共享。现在,您可以在创建SimpleShader
对象之前异步加载 GLSL 着色器文件。
-
编辑
shader_resources.js
并从text
和resource_map
模块导入功能: -
替换
init()
功能的内容。定义一个 JavaScriptpromise
、loadPromise
,异步加载两个 GLSL 着色器文件,加载完成后,触发createShaders()
函数的调用。通过调用map.pushPromise()
函数将loadPromise
存储在resource_map
的mOutstandingPromises
数组中:
import * as text from "../resources/text.js";
import * as map from "./resource_map.js";
function init() {
let loadPromise = new Promise(
async function(resolve) {
await Promise.all([
text.load(kSimpleFS),
text.load(kSimpleVS)
]);
resolve();
}).then(
function resolve() { createShaders(); }
);
map.pushPromise(loadPromise);
}
注意在shader_resources init()
函数之后,两个 GLSL 着色器文件的加载已经开始了。此时,不能保证加载操作已经完成,并且可能还没有创建SimpleShader
对象。然而,基于这些操作完成的承诺存储在resource_map mOutstandingPromises
数组中。因此,保证这些操作必须在resource_map waitOnPromises()
功能结束时完成。
修改 SimpleShader 以检索着色器文件
理解了 GLSL 着色器文件已经被加载,对SimpleShader
类的修改就很简单了。不用在loadAndCompileShader()
函数中同步加载着色器文件,这些文件的内容可以简单地通过text
资源检索。
-
编辑
simple_shader.js
文件,并从text
模块添加一个import
,用于检索 GLSL 着色器的内容: -
由于不需要加载操作,您应该将
loadAndCompileShader()
函数名改为简单的compileShader()
,并用text
资源检索代替文件加载命令。请注意,同步加载操作被一个对text.get()
的调用所取代,该调用基于filePath
或着色器文件的唯一资源名来检索文件内容。
import * as text from "./resources/text.js";
- 记住,在
SimpleShader
构造函数中,对loadAndCompileShader()
函数的调用应该被新修改的compileShader()
函数替换,如下所示:
function compileShader(filePath, shaderType) {
let shaderSource = null, compiledShader = null;
let gl = glSys.get();
// Step A: Access the shader textfile
shaderSource = text.get(filePath);
if (shaderSource === null) {
throw new Error("WARNING:" + filePath + " not loaded!");
return null;
}
... identical to previous code ...
}
constructor(vertexShaderPath, fragmentShaderPath) {
... identical to previous code ...
// Step A: load and compile vertex and fragment shaders
this.mVertexShader = compileShader(vertexShaderPath,
gl.VERTEX_SHADER);
this.mFragmentShader = compileShader(fragmentShaderPath,
gl.FRAGMENT_SHADER);
... identical to previous code ...
}
等待异步加载完成
由于突出的加载操作和不完整的着色器创建,客户端的游戏无法初始化,因为没有SimpleShader
,Renderable
对象无法正确创建。出于这个原因,游戏引擎必须等待所有未完成的承诺得到履行,然后才能初始化客户端的游戏。回想一下,客户端的游戏初始化是在游戏循环start()
函数中执行的,就在第一次循环迭代开始之前。
-
编辑
loop.js
文件并从resource_map
模块导入: -
将
start()
函数修改为async
函数,以便现在可以通过调用map.waitOnPromises()
来发出await
并暂停执行,以等待所有未完成承诺的履行;
import * as map from "./resource_map.js";
async function start(scene) {
if (mLoopRunning) {
throw new Error("loop already running")
}
// Wait for any async requests before game-load
await map.waitOnPromises();
mCurrentScene = scene;
mCurrentScene.init();
mPrevTime = performance.now();
mLagTime = 0.0;
mLoopRunning = true;
mFrameID = requestAnimationFrame(loopOnce);
}
测试异步着色器加载
现在,您可以在着色器异步加载的情况下运行项目。虽然输出和交互体验与之前的项目相同,但现在您有了一个更好地管理外部资源加载和访问的游戏引擎。
本章的其余部分进一步开发并形式化了客户端、MyGame
和游戏引擎其余部分之间的接口。目标是定义客户端的接口,以便在运行时可以创建和交换多个游戏级别的实例。有了这个新的界面,你将能够定义什么是游戏关卡,并允许游戏引擎以任何顺序加载任何关卡。
场景文件中的游戏关卡
从场景文件启动游戏级别所涉及的操作可以帮助游戏引擎和其客户端之间的正式接口的导出和改进。使用场景文件中定义的游戏级别,游戏引擎必须首先启动异步加载,等待加载完成,然后初始化游戏循环的客户端。这些步骤在游戏引擎和客户端之间提供了一个完整的功能接口。通过检查和获得对这些步骤的适当支持,游戏引擎和它的客户机之间的接口可以被改进。
场景文件项目
这个项目使用场景文件的加载作为工具来检查一个典型的游戏级别的必要的公共方法。你可以在图 4-4 中看到这个项目运行的例子。该项目的外观和交互方式与上一个项目相同,唯一的区别是场景定义是从文件中异步加载的。这个项目的源代码在chapter4/4.4.scene_file
文件夹中定义。
图 4-4
运行场景文件项目
该项目的控件与上一个项目相同,如下所示:
-
右箭头键:将白色方块向右移动,并将其绕到游戏窗口的左侧
-
向上箭头键:旋转白色方块
-
向下箭头键:增加红色方块的大小,然后在阈值处重新设置大小
该项目的目标如下:
-
介绍支持游戏资源异步加载的协议
-
为了开发适当的游戏引擎支持该协议
-
识别和定义一般游戏级别的公共接口方法
虽然游戏引擎设计者对场景文件的解析和加载过程很感兴趣,但是客户端永远不需要关心这些细节。这个项目旨在开发一个引擎和客户端之间定义良好的接口。该接口将对客户端隐藏引擎内部核心的复杂性,从而避免诸如在本章的第一个项目中需要从MyGame
访问loop
模块的情况。
场景文件
不是在init()
函数中将所有对象的创建硬编码到游戏中,而是将信息编码到一个文件中,并且可以在运行时加载和解析该文件。在外部文件中进行这种编码的优点是可以灵活地修改场景而不需要改变游戏源代码,而缺点是加载和解析所需的复杂性和时间。一般来说,灵活性的重要性决定了大多数游戏引擎支持从文件中加载游戏场景。
游戏场景中的对象可以用多种方式定义。关键的决定因素是该格式能够恰当地描述游戏对象并易于解析。可扩展标记语言(XML)非常适合作为场景文件的编码方案。
定义 XML 资源模块
为了支持 XML 编码的场景文件,首先需要扩展引擎以支持 XML 文件资源的异步加载。与text
资源模块类似,XML 资源模块也应该基于resource_map
:将加载的 XML 内容存储在resource_map
的mMap
中,并为resource_map
的loadDecodeParse()
函数的调用定义解码和解析的细节。
-
在
src/engine/resources
文件夹中定义一个新文件,并将其命名为xml.js
。编辑该文件并从resource_map
导入核心资源管理功能。 -
实例化一个 XML
DOMParser
,定义解码和解析函数,用相应的参数调用resource_map
的loadDecodeParse()
函数,开始加载 XML 文件:
"use strict"
import * as map from "../core/resource_map.js";
// functions from resource_map
let unload = map.unload;
let has = map.has;
let get = map.get;
- 记住导出定义的功能:
let mParser = new DOMParser();
function decodeXML(data) {
return data.text();
}
function parseXML(text) {
return mParser.parseFromString(text, "text/xml");
}
function load(path) {
return map.loadDecodeParse(path, decodeXML, parseXML);
}
- 最后,记住
export
在index.js
中为客户端定义的功能:
export {has, get, load, unload}
import * as xml from "./resources/xml.js";
... identical to previous code ...
export default {
// resource support
text, xml,
... identical to previous code ...
}
客户端可以方便地访问新定义的xml
模块,并以类似于text
模块的方式加载外部 XML 编码的文本文件。
Note
JavaScript DOMParser
提供了解析 XML 或 HTML 文本字符串的能力。
修改引擎以集成客户端资源加载
场景文件是由客户端加载的外部资源。对于异步操作,游戏引擎必须停止并等待加载过程的完成,然后才能初始化游戏。这是因为游戏初始化可能需要加载的资源。
在循环模块中协调客户端负载和引擎等待
由于所有的资源加载和存储都基于同一个resource_map
,客户端发出加载请求和引擎等待加载完成可以在loop.start()
函数中协调如下:
async function start(scene) {
if (mLoopRunning) {
throw new Error("loop already running")
}
mCurrentScene = scene;
mCurrentScene.load();
// Wait for any async requests before game-load
await map.waitOnPromises();
mCurrentScene.init();
mPrevTime = performance.now();
mLagTime = 0.0;
mLoopRunning = true;
mFrameID = requestAnimationFrame(loopOnce);
}
注意,这个函数与上一个项目正好有两行不同— mCurrentScene
被赋予一个参数的引用,在引擎等待所有异步加载操作完成之前,调用客户端的load()
函数。
为客户端派生一个公共接口
虽然有点复杂,但是 XML 解析的细节没有现在可以加载 XML 文件的事实重要。现在可以使用外部资源的异步加载来检查将游戏级别连接到游戏引擎所需的公共方法。
MyGame 的公共方法
虽然游戏引擎被设计来促进游戏的构建,但是游戏的实际状态是特定于每个单独的客户端的。一般来说,引擎无法预测初始化、更新或绘制任何特定游戏所需的操作。因此,这些操作被定义为游戏引擎和客户端之间的公共接口的一部分。此时,确定MyGame
应定义以下内容:
-
constructor()
:用于声明变量和定义常量。 -
init()
:用于实例化变量和设置游戏场景。这是在游戏循环第一次迭代之前从loop.start()
函数调用的。 -
draw()/update()
:用于连接游戏循环,这两个函数在loop.loopOnce()
函数中从游戏循环的核心连续调用。
根据加载场景文件或任何外部资源的要求,应该定义两个额外的公共方法:
-
load()
:启动外部资源的异步加载,这里是场景文件。这是在引擎等待所有异步加载操作完成之前从loop.start()
函数调用的。 -
unload()
:游戏结束时卸载外部资源。目前,引擎不会尝试释放资源。这将在下一个项目中得到纠正。
实现客户端
现在,您已经准备好创建一个 XML 编码的场景文件来测试客户端加载的外部资源,并基于所描述的公共方法通过游戏引擎与客户端进行交互。
定义场景文件
定义一个简单的场景文件来捕捉上一个项目中的游戏状态:
- 在与
src
文件夹相同的级别创建一个新文件夹,并将其命名为assets
。这是一个文件夹,游戏的所有外部资源或资产都将存储在其中,包括场景文件、音频剪辑、纹理图像和字体。
Tip
区分为组织游戏引擎源代码文件而创建的src/engine/resources
文件夹和为存储客户端资源而创建的assets
文件夹是很重要的。虽然 GLSL 着色器也在运行时加载,但它们被视为源代码,并将继续存储在src/glsl_shaders
文件夹中。
- 在
assets
文件夹中创建一个新文件,并将其命名为scene.xml
。这个文件将存储客户端的游戏场景。增加以下内容。列出的 XML 内容描述了与前面的MyGame
类的init()
函数中定义的场景相同的场景。
<MyGameLevel>
<!-- *** be careful!! comma (,) is not a supported syntax!! -->
<!-- make sure there are no comma in between attributes -->
<!-- e.g., do NOT do: PosX="20", PosY="30" -->
<!-- notice the "comma" between PosX and PosY: Syntax error! -->
<!-- cameras -->
<!-- Viewport: x, y, w, h -->
<Camera CenterX="20" CenterY="60" Width="20"
Viewport="20 40 600 300"
BgColor="0.8 0.8 0.8 1.0"
/>
<!-- Squares Rotation is in degree -->
<Square PosX="20" PosY="60" Width="5" Height="5"
Rotation="30" Color="1 1 1 1" />
<Square PosX="20" PosY="60" Width="2" Height="2"
Rotation="0" Color="1 0 0 1" />
</MyGameLevel>
Tip
JavaScript XML 解析器不支持用逗号分隔属性。
解析场景文件
必须为列出的 XML 场景文件定义一个特定的解析器来提取场景信息。由于场景文件是特定于游戏的,所以解析器也应该是特定于游戏的,并在my_game
文件夹中创建。
-
在
src/my_game
文件夹中新建一个文件夹,命名为util
。在util
文件夹中添加一个新文件,命名为scene_file_parser.js
。该文件将包含特定的解析逻辑来解码列出的场景文件。 -
定义一个新类,将其命名为
SceneFileParser
,并添加一个构造函数,代码如下:
import engine from "../../engine/index.js";
class SceneFileParser {
constructor (xml) {
this.xml = xml
}
... implementation to follow ...
}
注意,xml
参数是加载的 XML 文件的实际内容。
Note
下面的 XML 解析基于 JavaScript XML API。更多详情请参考 https://www.w3schools.com/xml
。
- 向
SceneFileParser
添加一个函数,从您创建的xml
文件中解析Camera
的细节:
parseCamera() {
let camElm = getElm(this.xml, "Camera");
let cx = Number(camElm[0].getAttribute("CenterX"));
let cy = Number(camElm[0].getAttribute("CenterY"));
let w = Number(camElm[0].getAttribute("Width"));
let viewport = camElm[0].getAttribute("Viewport").split(" ");
let bgColor = camElm[0].getAttribute("BgColor").split(" ");
// make sure viewport and color are number
let j;
for (j = 0; j < 4; j++) {
bgColor[j] = Number(bgColor[j]);
viewport[j] = Number(viewport[j]);
}
let cam = new engine.Camera(
vec2.fromValues(cx, cy), // position of the camera
w, // width of camera
viewport // viewport (orgX, orgY, width, height)
);
cam.setBackgroundColor(bgColor);
return cam;
}
相机解析器找到一个相机元素,并用检索到的信息构建一个Camera
对象。请注意,视窗和背景颜色是由四个数字组成的数组。这些输入是由空格分隔的四个数字的字符串。字符串可以拆分成数组,这里使用空格分隔符就是这种情况。JavaScript Number()
函数确保所有的字符串都被转换成数字。
- 向
SceneFileParser
添加一个函数,从您创建的xml
文件中解析方块的细节:
parseSquares(sqSet) {
let elm = getElm(this.xml, "Square");
let i, j, x, y, w, h, r, c, sq;
for (i = 0; i < elm.length; i++) {
x = Number(elm.item(i).attributes.getNamedItem("PosX").value);
y = Number(elm.item(i).attributes.getNamedItem("PosY").value);
w = Number(elm.item(i).attributes.getNamedItem("Width").value);
h = Number(elm.item(i).attributes.getNamedItem("Height").value);
r = Number(elm.item(i).attributes.getNamedItem("Rotation").value);
c = elm.item(i).attributes.getNamedItem("Color").value.split(" ");
sq = new engine.Renderable();
// make sure color array contains numbers
for (j = 0; j < 4; j++) {
c[j] = Number(c[j]);
}
sq.setColor(c);
sq.getXform().setPosition(x, y);
sq.getXform().setRotationInDegree(r); // In Degree
sq.getXform().setSize(w, h);
sqSet.push(sq);
}
}
该函数解析 XML 文件以创建Renderable
对象,这些对象将被放入作为参数传入的数组中。
-
在
SceneFileParser
之外添加一个函数来解析 XML 元素的内容: -
最后,导出
SceneFileParser
:
function getElm(xmlContent, tagElm) {
let theElm = xmlContent.getElementsByTagName(tagElm);
if (theElm.length === 0) {
console.error("Warning: Level element:[" +
tagElm + "]: is not found!");
}
return theElm;
}
export default SceneFileParser;
实现我的游戏
本项目描述的公共函数的实现如下:
-
编辑
my_game.js
文件并导入SceneFileParser
: -
修改
MyGame
构造函数来定义场景文件路径、存储Renderable
对象的数组mSqSet
和camera
:
import SceneFileParser from "./util/scene_file_parser.js";
- 更改
init()
函数以创建基于场景解析器的对象。注意通过engine.xml.get()
函数检索 XML 文件内容,其中场景文件的文件路径被用作关键字。
constructor() {
// scene file name
this.mSceneFile = "assets/scene.xml";
// all squares
this.mSqSet = []; // these are the Renderable objects
// The camera to view the scene
this.mCamera = null;
}
- 除了引用相应的数组元素之外,draw 和 update 函数与前面的示例类似。
init() {
let sceneParser = new SceneFileParser(
engine.xml.get(this.mSceneFile));
// Step A: Read in the camera
this.mCamera = sceneParser.parseCamera();
// Step B: Read all the squares
sceneParser.parseSquares(this.mSqSet);
}
- 最后,定义加载和卸载场景文件的函数。
draw() {
// Step A: clear the canvas
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]);
this.mCamera.setViewAndCameraMatrix();
// Step B: draw all the squares
let i;
for (i = 0; i < this.mSqSet.length; i++)
this.mSqSet[i].draw(this.mCamera);
}
update() {
// simple game: move the white square and pulse the red
let xform = this.mSqSet[0].getXform();
let deltaX = 0.05;
// Step A: test for white square movement
... identical to previous code ...
xform = this.mSqSet[1].getXform();
// Step C: test for pulsing the red square
... identical to previous code ...
}
load() {
engine.xml.load(this.mSceneFile);
}
unload() {
// unload the scene file and loaded resources
engine.xml.unload(this.mSceneFile);
}
您现在可以运行该项目,并看到它的行为与前两个项目相同。虽然这可能看起来不有趣,但通过这个项目,引擎和客户端之间的简单且定义良好的接口已经被派生出来,其中隐藏了每一个的复杂性和细节。基于这个接口,可以引入额外的引擎功能,而不需要修改任何现有的客户端,同时,可以独立于引擎内部来创建和维护复杂的游戏。这个接口的细节将在下一个项目中介绍。
在继续之前,您可能会注意到从未调用过MyGame.unload()
函数。这是因为在这个例子中,游戏循环从未停止循环,并且MyGame
从未被卸载。这个问题将在下一个项目中解决。
场景对象:游戏引擎的客户端界面
此时,在您的游戏引擎中,会发生以下情况:
-
window.onload
函数初始化游戏引擎并调用loop.start()
函数,将MyGame
作为参数传入。 -
loop.start()
函数通过resource_map
等待所有异步加载操作完成后,调用初始化MyGame
,开始实际的游戏循环周期。
从这个讨论中,有趣的是认识到任何具有适当定义的公共方法的对象都可以替换MyGame
对象。实际上,在任何时候,都可以调用loop.start()
函数来启动新场景的加载。本节通过介绍用于游戏引擎和客户端接口的Scene
对象来扩展这个想法。
场景对象项目
这个项目将Scene
定义为一个抽象超类,用于与你的游戏引擎接口。从这个项目开始,所有的客户端代码都必须封装在抽象的Scene
类的子类中,游戏引擎将能够以一致和定义良好的方式与这些类进行交互。你可以在图 4-5 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.5.scene_objects
文件夹中定义。
图 4-5
使用两个场景运行场景对象项目
在这个项目中有两个不同的级别:MyGame
级别,在灰色背景上的红色正方形上方绘制蓝色矩形,以及BlueLevel
级别,在深蓝色背景上的旋转白色正方形上方绘制红色矩形。为简单起见,两个级别的控件是相同的。
-
左/右箭头键:左右移动前矩形
-
Q 键??:退出游戏
请注意,在每个级别上,向左移动前面的矩形以接触左边界将导致另一个级别的加载。MyGame
电*将导致BlueLevel
被加载,BlueLevel
将导致MyGame
电*被加载。
该项目的目标如下:
-
定义抽象的
Scene
类来连接游戏引擎 -
体验游戏引擎对场景转换的支持
-
创建场景特定的加载和卸载支持
抽象场景类
根据之前项目的经验,一个用于封装游戏引擎接口的抽象Scene
类必须至少定义这些功能:init()
、draw()
、update()
、load()
和unload()
。这个列表中缺少的是对等级转换到start
,升级到next
等级的支持,如果需要的话,还有对stop
游戏的支持。
- 在
src/engine
文件夹中新建一个 JavaScript 文件,命名为scene.js
,从loop
模块和引擎访问文件index.js
中导入。这两个模块是必需的,因为Scene
对象必须在游戏关卡开始和结束时开始和结束游戏循环,如果一个关卡应该决定终止游戏,则必须清理引擎。
import * as loop from "./core/loop.js";
import engine from "./index.js";
Note
在Scene
开始之前,游戏循环不得运行。这是因为在从正在运行的游戏循环中调用Scene
的update()
函数之前,必须正确加载所需的资源。类似地,只有在游戏循环停止后才能卸载关卡。
-
定义 JavaScript
Error
对象,用于在误用的情况下警告客户端: -
创建一个名为
Scene
的新类并导出它:
const kAbstractClassError = new Error("Abstract Class")
const kAbstractMethodError = new Error("Abstract Method")
- 实现构造函数以确保只有
Scene
类的子类被实例化:
class Scene { ... implementation to follow ... }
export default Scene;
- 定义场景转换功能:
start()
、next()
、stop()
。start()
函数是一个异步函数,因为它负责启动游戏循环,然后等待所有异步加载完成。next()
和stop()
函数都停止游戏循环,并调用unload()
函数卸载加载的资源。不同之处在于next()
函数将被覆盖,并从子类中调用,在卸载当前场景后,子类可以继续前进到下一个级别。卸载后,stop()
功能假定游戏已经终止,并继续清理游戏引擎。
constructor() {
if (this.constructor === Scene) {
throw kAbstractClassError
}
}
- 定义其余的派生接口函数。注意,
Scene
类是一个抽象类,因为所有的接口函数都是空的。虽然子类可以选择只实现接口函数的一个选择性子集,但是draw()
和update()
函数不是可选的,因为它们一起构成了一个级别的核心。
async start() {
await loop.start(this);
}
next() {
loop.stop();
this.unload();
}
stop() {
loop.stop();
this.unload();
engine.cleanUp();
}
init() { /* to initialize the level (called from loop.start()) */ }
load() { /* to load necessary resources */ }
unload() { /* unload all resources */ }
// draw/update must be over-written by subclass
draw() { throw kAbstractMethodError; }
update() { throw kAbstractMethodError; }
这些功能共同提供了一个与游戏引擎交互的协议。预计子类将覆盖这些函数来实现实际的游戏行为。
Note
JavaScript 不支持抽象类。该语言不阻止游戏程序员实例化一个Scene
对象;但是,创建的实例将完全没有用,错误消息将向它们提供适当的警告。
修改游戏引擎以支持场景类
游戏引擎必须在两个重要方面进行修改。首先,必须修改游戏引擎访问文件index.js
,以便将新引入的符号导出到客户端,就像所有新功能一样。其次,Scene.stop()
函数引入了停止游戏的可能性,并处理所需的清理和资源释放。
将场景类导出到客户端
编辑从scene.js
导入的index.js
文件,并为客户端导出Scene
:
... identical to previous code ...
import Scene from "./scene.js";
... identical to previous code ...
export default {
... identical to previous code ...
Camera, Scene, Transform, Renderable,
... identical to previous code ...
}
实现引擎清理支持
当游戏引擎关闭时,释放分配的资源是很重要的。清理过程相当复杂,并且与系统组件初始化的顺序相反。
- 再次编辑 index.js ,这次是为了实现对游戏引擎清理的支持。从
loop
模块导入,然后定义并导出cleanup()
函数。
... identical to previous code ...
import * as loop from "./core/loop.js";
... identical to previous code ...
function cleanUp() {
loop.cleanUp();
input.cleanUp();
shaderResources.cleanUp();
vertexBuffer.cleanUp();
glSys.cleanUp();
}
... identical to previous code ...
export default {
... identical to previous code ...
init, cleanUp, clearCanvas
... identical to previous code ...
}
Note
类似于其他核心引擎内部组件,如gl
或vertex_buffer
, loop
不应该被客户端访问。为此,loop
模块由index.js
导入而非导出,导入使得游戏循环清理可被调用,而非导出,使得客户端可被屏蔽于引擎内不相关的复杂性。
请注意,没有一个组件定义了它们相应的清理函数。你现在可以补救了。在以下每种情况下,一定要记得在适当的时候导出新定义的cleanup()
函数。
-
编辑
loop.js
以定义并导出一个cleanUp()
函数来停止游戏循环并卸载当前活动场景: -
编辑
input.js
定义并导出一个cleanUp()
函数。目前,没有具体的资源需要释放。
... identical to previous code ...
function cleanUp() {
if (mLoopRunning) {
stop();
// unload all resources
mCurrentScene.unload();
mCurrentScene = null;
}
}
export {start, stop, cleanUp}
- 编辑
shader_resources.js
以定义并导出一个cleanUp()
函数来清理创建的着色器并卸载其源代码:
... identical to previous code ...
function cleanUp() {} // nothing to do for now
export {keys, init, cleanUp,
... identical to previous code ...
- 编辑
simple_shader.js
为SimpleShader
类定义cleanUp()
函数来释放分配的 WebGL 资源:
... identical to previous code ...
function cleanUp() {
mConstColorShader.cleanUp();
text.unload(kSimpleVS);
text.unload(kSimpleFS);
}
export {init, cleanUp, getConstColorShader}
- 编辑
vertex_buffer.js
以定义并导出一个cleanUp()
函数来删除分配的缓冲存储器:
cleanUp() {
let gl = glSys.get();
gl.detachShader(this.mCompiledShader, this.mVertexShader);
gl.detachShader(this.mCompiledShader, this.mFragmentShader);
gl.deleteShader(this.mVertexShader);
gl.deleteShader(this.mFragmentShader);
gl.deleteProgram(this.mCompiledShader);
}
- 最后,编辑
gl.js
来定义并导出一个cleanUp()
函数来通知玩家引擎现在已经关闭:
... identical to previous code ...
function cleanUp() {
if (mGLVertexBuffer !== null) {
glSys.get().deleteBuffer(mGLVertexBuffer);
mGLVertexBuffer = null;
}
}
export {init, get, cleanUp}
... identical to previous code ...
function cleanUp() {
if ((mGL == null) || (mCanvas == null))
throw new Error("Engine cleanup: system is not initialized.");
mGL = null;
// let the user know
mCanvas.style.position = "fixed";
mCanvas.style.backgroundColor = "rgba(200, 200, 200, 0.5)";
mCanvas = null;
document.body.innerHTML +=
"<br><br><h1>End of Game</h1><h1>GL System Shut Down</h1>";
}
export {init, get, cleanUp}
测试游戏引擎的场景类接口
通过抽象的Scene
类定义和对游戏引擎核心组件的资源管理修改,现在可以随意停止现有场景和加载新场景。本节在场景类的两个子类MyGame
和BlueLevel
之间循环,以说明场景的加载和卸载。
为了简单起见,这两个测试场景与上一个项目中的MyGame
场景几乎相同。在这个项目中,MyGame
在init()
函数中显式定义场景,而BlueScene
以与上一个项目相同的方式,从位于assets
文件夹中的blue_level.xml
文件中加载场景内容。XML 场景文件的内容和解析与前一个项目中的内容和解析相同,因此不再重复。
我的游戏场景
如上所述,这个场景在init()
函数中定义了与前一个项目的场景文件中相同的内容。在下一节中,请注意对next()
和stop()
函数的定义和调用。
-
编辑
my_game.js
从index.js
和新定义的blue_level.js
导入。注意,有了Scene
类的支持,您不再需要从loop
模块导入。 -
将
MyGame
定义为引擎Scene
类的子类,记住要导出MyGame
:
import engine from "../engine/index.js";
import BlueLevel from "./blue_level.js";
class MyGame extends engine.Scene {
... implementation to follow ...
}
export default MyGame;
Note
JavaScript extends
关键字定义了父/子关系。
-
定义
constructor()
、init()
和draw()
功能。请注意,在init()
功能中定义的场景内容,除了摄像机背景颜色之外,与之前的项目相同。 -
定义
update()
功能;注意当mHero
对象从右边越过x=11
边界时的this.next()
调用,以及当 Q 键被按下时的this.stop()
调用。
constructor() {
super();
// The camera to view the scene
this.mCamera = null;
// the hero and the support objects
this.mHero = null;
this.mSupport = null;
}
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(20, 60), // position of the camera
20, // width of camera
[20, 40, 600, 300] // viewport (orgX, orgY, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// Step B: Create the support object in red
this.mSupport = new engine.Renderable();
this.mSupport.setColor([0.8, 0.2, 0.2, 1]);
this.mSupport.getXform().setPosition(20, 60);
this.mSupport.getXform().setSize(5, 5);
// Step C: Create the hero object in blue
this.mHero = new engine.Renderable();
this.mHero.setColor([0, 0, 1, 1]);
this.mHero.getXform().setPosition(20, 60);
this.mHero.getXform().setSize(2, 3);
}
draw() {
// Step A: clear the canvas
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]);
// Step B: Activate the drawing Camera
this.mCamera.setViewAndCameraMatrix();
// Step C: draw everything
this.mSupport.draw(this.mCamera);
this.mHero.draw(this.mCamera);
}
- 定义
next()
函数过渡到BlueLevel
场景:
update() {
// let's only allow the movement of hero,
// and if hero moves too far off, this level ends, we will
// load the next level
let deltaX = 0.05;
let xform = this.mHero.getXform();
// Support hero movements
if (engine.input.isKeyPressed(engine.input.keys.Right)) {
xform.incXPosBy(deltaX);
if (xform.getXPos() > 30) { // right-bound of the window
xform.setPosition(12, 60);
}
}
if (engine.input.isKeyPressed(engine.input.keys.Left)) {
xform.incXPosBy(-deltaX);
if (xform.getXPos() < 11) { // left-bound of the window
this.next();
}
}
if (engine.input.isKeyPressed(engine.input.keys.Q))
this.stop(); // Quit the game
}
next() {
super.next(); // this must be called!
// next scene to run
let nextLevel = new BlueLevel(); // next level to be loaded
nextLevel.start();
}
Note
这个super.next()
调用,其中超类可以停止游戏循环并导致这个场景的卸载,在导致场景转换中是必要的并且绝对关键的。
- 最后,修改
window.onload()
函数,用一个客户端友好的myGame.start()
函数替换对loop
模块的访问:
window.onload = function () {
engine.init("GLCanvas");
let myGame = new MyGame();
myGame.start();
}
蓝色场景
除了支持新的场景类和场景转换之外,BlueLevel
场景与上一个项目中的MyGame
对象几乎相同:
-
在
my_game
文件夹下创建并编辑blue_level.js
文件,从index.js
、MyGame
和SceneFileParser
引擎导入。将BlueLevel
定义并导出为engine.Scene
类的子类。 -
定义
init()
、draw()
、load()
和unload()
函数,使其与前一个项目的MyGame
类中的函数相同。 -
定义类似于
MyGame
场景的update()
功能。再一次,注意当对象从右边越过x=11
边界时的this.next()
调用和当 Q 键被按下时的this.stop()
调用。
// Engine Core stuff
import engine from "../engine/index.js";
// Local stuff
import MyGame from "./my_game.js";
import SceneFileParser from "./util/scene_file_parser.js";
class BlueLevel extends engine.Scene {
... implementation to follow ...
}
export default BlueLevel
- 最后,定义
next()
函数来过渡到MyGame
场景。值得重申的是,调用super.next()
是必要的,因为在进入下一个场景之前,停止游戏循环并卸载当前场景是至关重要的。
update() {
// For this very simple game, let's move the first square
let xform = this.mSQSet[1].getXform();
let deltaX = 0.05;
/// Move right and swap over
if (engine.input.isKeyPressed(engine.input.keys.Right)) {
xform.incXPosBy(deltaX);
if (xform.getXPos() > 30) { // right-bound of the window
xform.setPosition(12, 60);
}
}
// test for white square movement
if (engine.input.isKeyPressed(engine.input.keys.Left)) {
xform.incXPosBy(-deltaX);
if (xform.getXPos() < 11) { // this is the left-boundary
this.next(); // go back to my game
}
}
if (engine.input.isKeyPressed(engine.input.keys.Q))
this.stop(); // Quit the game
}
next() {
super.next();
let nextLevel = new MyGame(); // load the next level
nextLevel.start();
}
现在,您可以运行项目,查看卸载和装载场景,并在互动过程中随时退出游戏。您的游戏引擎现在有了一个定义良好的界面来与它的客户端协同工作。这个接口遵循定义良好的Scene
类协议。
-
constructor()
:用于声明变量和定义常量。 -
start()
/stop()
:用于开始一个场景和停止游戏。这两个方法并不意味着被子类覆盖。
下面的接口方法应该被子类覆盖。
-
init()
:用于实例化变量和设置游戏场景。 -
load()
/unload()
:用于发起外部资源的异步加载和卸载。 -
draw()
/update()
:连续显示游戏状态,接收玩家输入,执行游戏逻辑。 -
next()
:用于实例化和过渡到下一个场景。最后,作为最后的提醒,子类调用super.next()
来停止游戏循环并卸载场景是绝对关键的。
任何定义这些方法的对象都可以被你的游戏引擎加载并与之交互。您可以尝试创建其他级别。
声音的
音频是所有视频游戏的基本元素。一般来说,游戏中的音效分为两类。第一类是背景音频。这包括背景音乐或环境效果,通常用于给游戏的不同部分带来气氛或情绪。第二类是音效。音效对各种用途都很有用,从通知用户游戏动作到听到你的英雄人物的脚步声。通常,音效代表一个特定的动作,由用户或游戏本身触发。这种声音效果通常被认为是音频提示。
这两种音频的一个重要区别是你如何控制它们。声音效果或提示一旦开始就不能停止或调整其音量;因此,线索一般都很短。另一方面,背景音频可以随意启动和停止。这些功能对于完全停止背景轨道并开始另一个轨道非常有用。
音频支持项目
这个项目有与前一个项目相同的MyGame
和BlueLevel
场景。你可以用箭头键向左或向右移动前面的矩形,与左边界的交点触发另一个场景的加载,Q 键退出游戏。然而,在这个版本中,当按下左/右箭头键时,每个场景都会播放背景音乐并触发一个简短的音频提示。请注意,每种类型的音频剪辑的音量都不同。这个项目的实施也加强了加载和卸载外部资源和音频剪辑本身的概念。你可以在图 4-6 中看到这个项目运行的例子。这个项目的源代码在chapter4/4.6.audio_support
文件夹中定义。
图 4-6
在两个场景中运行音频支持项目
该项目的控制措施如下:
-
左/右箭头键:左右移动前面的矩形,增加或减少背景音乐的音量
-
Q 键??:退出游戏
该项目的目标如下:
-
向资源管理系统添加音频支持
-
为游戏提供播放音频的界面
您可以在assets/sounds
文件夹中找到以下音频文件:
-
bg_clip.mp3
-
blue_level_cue.wav
-
my_game_cue.wav
注意,音频文件有两种格式,mp3
和wav
。虽然两者都受支持,但使用这些格式的音频文件时应小心。.mp3
格式的文件经过压缩,适合存储更长时间的音频内容,例如背景音乐。.wav
格式的文件是未压缩的,应该只包含非常短的音频片段,例如,用于存储提示效果。
定义一个音频资源模块
虽然音频和文本文件完全不同,但从游戏引擎实现的角度来看,有两个重要的相似之处。首先,两者都是外部资源,因此将被实现为类似于src/engine/resources
文件夹中的引擎组件。第二,两者都涉及带有定义良好的 API 实用程序的标准化文件格式。网络音频 API 将用于声音文件的实际检索和播放。尽管这个 API 提供了巨大的能力,为了关注游戏引擎开发的其余部分,只讨论了对背景音频和效果提示的基本支持。
Note
感兴趣的读者可以从 www.w3.org/TR/webaudio/
了解更多 Web 音频 API。
包括 Chrome 在内的一些浏览器的最新政策是,在用户第一次交互之前,不允许播放音频。这意味着上下文创建将导致 Chrome 发出一个初始警告,并输出到运行时浏览器控制台。音频将仅在用户输入(例如,鼠标点击或键盘事件)后播放。
-
在
src/engine/resources
文件夹中,创建一个新文件并将其命名为audio.js
。这个文件将实现音频组件的模块。该组件必须支持两种类型的功能:加载和卸载音频文件以及为游戏开发者播放和控制音频文件的内容。 -
加载和卸载类似于
text
和xml
模块的实现,其中核心资源管理功能是从resource_map
导入的: -
定义解码和解析函数,调用
resource_map loadDecodeParse()
函数加载音频文件。注意,有了resource_map
和引擎基础设施的支持,外部资源的加载和卸载变得简单了。
"use strict";
import * as map from "../core/resource_map.js";
// functions from resource_map
let unload = map.unload;
let has = map.has;
- 加载功能完成后,您现在可以定义音频控制和操作功能。声明变量来维护对 Web 音频上下文和背景音乐的引用,并控制音量。
function decodeResource(data) { return data.arrayBuffer(); }
function parseResource(data) {
return mAudioContext.decodeAudioData(data); }
function load(path) {
return map.loadDecodeParse(path, decodeResource, parseResource);
}
- 定义
init()
函数,在mAudioContext
中创建并存储对 Web 音频上下文的引用,并为影响两者的背景、提示和母版初始化音量增益控制。在所有情况下,音量增益 0 对应于没有音频,1 表示最大音量。
let mAudioContext = null;
let mBackgroundAudio = null;
// volume control support
let mBackgroundGain = null; // background volume
let mCueGain = null; // cue/special effects volume
let mMasterGain = null; // overall/master volume
let kDefaultInitGain = 0.1;
- 定义
playCue()
功能,通过适当的音量控制播放音频剪辑的整个持续时间。这个函数使用音频文件路径作为资源名,从resource_map
中找到加载的资产,然后调用 Web 音频 API 来播放音频剪辑。注意,没有保存对source
变量的引用,因此一旦开始,就没有办法停止相应的音频剪辑。一个游戏应该调用这个函数来播放音频剪辑的小片段作为提示。
function init() {
try {
let AudioContext = window.AudioContext ||
window.webkitAudioContext;
mAudioContext = new AudioContext();
// connect Master volume control
mMasterGain = mAudioContext.createGain();
mMasterGain.connect(mAudioContext.destination);
// set default Master volume
mMasterGain.gain.value = kDefaultInitGain;
// connect Background volume control
mBackgroundGain = mAudioContext.createGain();
mBackgroundGain.connect(mMasterGain);
// set default Background volume
mBackgroundGain.gain.value = 1.0;
// connect Cuevolume control
mCueGain = mAudioContext.createGain();
mCueGain.connect(mMasterGain);
// set default Cue volume
mCueGain.gain.value = 1.0;
} catch (e) {
throw new Error("...");
}
}
- 定义播放、停止、查询和控制背景音乐音量的功能。在这种情况下,
mBackgroundAudio
变量保持对当前播放音频的引用,因此,可以停止剪辑或改变其音量。
function playCue(path, volume) {
let source = mAudioContext.createBufferSource();
source.buffer = map.get(path);
source.start(0);
// volume support for cue
source.connect(mCueGain);
mCueGain.gain.value = volume;
}
- 定义控制主音量的功能,主音量可调节提示和背景音乐的音量:
function playBackground(path, volume) {
if (has(path)) {
stopBackground();
mBackgroundAudio = mAudioContext.createBufferSource();
mBackgroundAudio.buffer = map.get(path);
mBackgroundAudio.loop = true;
mBackgroundAudio.start(0);
// connect volume accordingly
mBackgroundAudio.connect(mBackgroundGain);
setBackgroundVolume(volume);
}
}
function stopBackground() {
if (mBackgroundAudio !== null) {
mBackgroundAudio.stop(0);
mBackgroundAudio = null;
}
}
function isBackgroundPlaying() {
return (mBackgroundAudio !== null);
}
function setBackgroundVolume(volume) {
if (mBackgroundGain !== null) {
mBackgroundGain.gain.value = volume;
}
}
function incBackgroundVolume(increment) {
if (mBackgroundGain !== null) {
mBackgroundGain.gain.value += increment;
// need this since volume increases when negative
if (mBackgroundGain.gain.value < 0) {
setBackgroundVolume(0);
}
}
}
- 定义一个
cleanUp()
函数来释放分配的 HTML5 资源:
function setMasterVolume(volume) {
if (mMasterGain !== null) {
mMasterGain.gain.value = volume;
}
}
function incMasterVolume(increment) {
if (mMasterGain !== null) {
mMasterGain.gain.value += increment;
// need this since volume increases when negative
if (mMasterGain.gain.value < 0) {
mMasterGain.gain.value = 0;
}
}
}
- 记住
export
该模块的功能:
function cleanUp() {
mAudioContext.close();
mAudioContext = null;
}
export {init, cleanUp,
has, load, unload,
playCue,
playBackground, stopBackground, isBackgroundPlaying,
setBackgroundVolume, incBackgroundVolume,
setMasterVolume, incMasterVolume
}
将音频模块导出到客户端
编辑从audio.js
导入的index.js
文件,相应地初始化和清理模块,并导出到客户端:
... identical to previous code ...
import * as audio from "./resources/audio.js";
... identical to previous code ...
function init(htmlCanvasID) {
glSys.init(htmlCanvasID);
vertexBuffer.init();
shaderResources.init();
input.init();
audio.init();
}
function cleanUp() {
loop.cleanUp();
audio.cleanUp();
input.cleanUp();
shaderResources.cleanUp();
vertexBuffer.cleanUp();
glSys.cleanUp();
}
... identical to previous code ...
export default {
// resource support
audio, text, xml
... identical to previous code ...
}
测试音频组件
要测试音频组件,您必须将必要的音频文件复制到游戏项目中。在assets
文件夹中新建一个文件夹,命名为sounds
。将bg_clip.mp3
、blue_level_cue.wav
和my_game_cue.wav
文件复制到sounds
文件夹中。您现在需要更新MyGame
和BlueLevel
实现来加载和使用这些音频资源。
改变我的游戏. js
更新MyGame
场景以加载音频剪辑,播放背景音频,并在箭头键被按下时提示玩家:
-
在构造函数中声明音频文件的常量文件路径。回想一下,这些文件路径被用作加载、存储和检索的资源名称。将这些声明为常量以备后用是一个好的软件工程实践。
-
在
load()
函数中请求加载音频剪辑,并确保定义了相应的unload()
函数。请注意,卸载背景音乐之前会停止播放音乐。通常,资源的操作必须在卸载前停止。
constructor() {
super();
// audio clips: supports both mp3 and wav formats
this.mBackgroundAudio = "assets/sounds/bg_clip.mp3";
this.mCue = "assets/sounds/my_game_cue.wav";
... identical to previous code ...
}
- 在
init()
功能结束时启动背景音频。
load() {
// loads the audios
engine.audio.load(this.mBackgroundAudio);
engine.audio.load(this.mCue);
}
unload() {
// Step A: Game loop not running, unload all assets
// stop the background audio
engine.audio.stopBackground();
// unload the scene resources
engine.audio.unload(this.mBackgroundAudio);
engine.audio.unload(this.mCue);
}
- 在
update()
功能中,当左右箭头键被按下时提示玩家,并增大和减小背景音乐的音量:
init() {
... identical to previous code ...
// now start the Background music ...
engine.audio.playBackground(this.mBackgroundAudio, 1.0);
}
update() {
... identical to previous code ...
// Support hero movements
if (engine.input.isKeyPressed(engine.input.keys.Right)) {
engine.audio.playCue(this.mCue, 0.5);
engine.audio.incBackgroundVolume(0.05);
xform.incXPosBy(deltaX);
if (xform.getXPos() > 30) { // right-bound of the window
xform.setPosition(12, 60);
}
}
if (engine.input.isKeyPressed(engine.input.keys.Left)) {
engine.audio.playCue(this.mCue, 1.5);
engine.audio.incBackgroundVolume(-0.05);
xform.incXPosBy(-deltaX);
if (xform.getXPos() < 11) { // left-bound of the window
this.next();
}
}
... identical to previous code ...
}
更改 BlueLevel.js
对BlueLevel
场景的改变与对MyGame
场景的改变相似,但是具有不同的音频提示:
-
在
BlueLevel
构造函数中,向音频资源添加以下路径名: -
修改音频剪辑的
load()
和unload()
功能:
constructor() {
super();
// audio clips: supports both mp3 and wav formats
this.mBackgroundAudio = "assets/sounds/bg_clip.mp3";
this.mCue = "assets/sounds/blue_level_cue.wav";
... identical to previous code ...
}
- 与
MyGame
一样,在init()
功能中启动背景音频,在update()
功能中按下左右键时提示玩家。请注意,在这种情况下,音频提示以不同的音量设置播放。
load() {
engine.xml.load(this.mSceneFile);
engine.audio.load(this.mBackgroundAudio);
engine.audio.load(this.mCue);
}
unload() {
// stop the background audio
engine.audio.stopBackground();
// unload the scene file and loaded resources
engine.xml.unload(this.mSceneFile);
engine.audio.unload(this.mBackgroundAudio);
engine.audio.unload(this.mCue);
}
init() {
... identical to previous code ...
// now start the Background music ...
engine.audio.playBackground(this.mBackgroundAudio, 0.5);
}
update() {
... identical to previous code ...
// Move right and swap over
if (engine.input.isKeyPressed(engine.input.keys.Right)) {
engine.audio.playCue(this.mCue, 0.5);
xform.incXPosBy(deltaX);
if (xform.getXPos() > 30) { // right-bound of the window
xform.setPosition(12, 60);
}
}
// Step A: test for white square movement
if (engine.input.isKeyPressed(engine.input.keys.Left)) {
engine.audio.playCue(this.mCue, 1.0);
xform.incXPosBy(-deltaX);
if (xform.getXPos() < 11) { // this is the left-boundary
this.next(); // go back to my game
}
}
... identical to previous code ...
}
你现在可以运行项目,并听取美妙的音频反馈。如果你按住箭头键,会有许多线索反复播放。事实上,有太多的线索回响,以至于声音效果被模糊成一个恼人的爆炸。这是一个很好的例子,说明了小心使用音频提示并确保每个单独的提示又好又短的重要性。你可以尝试点击箭头键来听更清晰、更悦耳的提示,或者你可以简单地用isKeyClicked()
功能替换isKeyPressed()
功能,然后听每个单独的提示。
摘要
在这一章中,你学习了游戏引擎的几个常见组件是如何组合在一起的。从非常重要的游戏循环开始,您了解了它如何实现输入、更新和绘制模式,以便超越人类的感知或欺骗我们的感官,让我们相信系统是连续的、实时运行的。这种模式是任何游戏引擎的核心。您了解了如何灵活和可重用地实现完整的键盘支持,从而为引擎提供可靠的输入组件。此外,您还看到了如何实现资源管理器来异步加载文件,以及如何抽象场景以支持从文件加载场景,这可以大大减少代码中的重复。最后,您了解了音频支持如何为客户端提供一个接口来加载和播放环境背景音频和音频提示。
这些组件单独来看几乎没有共同点,但合在一起构成了几乎所有游戏的核心基础。当您将这些核心组件实现到游戏引擎中时,用该引擎创建的游戏将无需担心每个组件的细节。相反,游戏程序员可以专注于利用功能来加速和简化开发过程。在下一章,你将学习如何用外部图像创建动画的幻觉。
游戏设计注意事项
在这一章中,我们讨论了游戏循环以及有助于玩家行为和游戏反应之间联系的技术基础。例如,如果玩家选择了屏幕上绘制的一个方块,并使用箭头键将其从位置 A 移动到位置 B,您通常会希望该动作在箭头键被按下时立即开始*滑运动,没有口吃、延迟或明显的滞后。游戏循环对游戏设计中所谓的存在做出了重大贡献;存在感是玩家感觉自己与游戏世界联系在一起的能力,而响应能力在让玩家感觉联系在一起方面起着关键作用。当现实世界中的动作(如按下箭头键)无缝地转化为游戏世界中的反应(如移动物体、翻转开关、跳跃等)时,临场感得到加强;当现实世界中的行动遭遇翻译错误(如延迟和滞后)时,临场感就会受到影响。
正如在第一章中提到的,有效的游戏机制设计可以从一些简单的元素开始。例如,当你完成本章中的键盘支持项目时,许多部分已经准备好开始构建游戏关卡:你已经为玩家提供了操纵屏幕上两个独立元素(红色和白色方块)的能力,剩下的就是使用这些元素设计一个因果链,当完成时会产生一个新的事件。想象一下键盘支持项目是你的游戏:你会如何利用现有的东西来创建一个因果链?你可能会选择玩方块之间的关系,也许需要将红色方块完全移动到白色方块内,以便解锁下一个挑战;一旦玩家成功地将红色方块放置在白色方块中,这一关就完成了。这种基本机制本身可能不足以创造一种引人入胜的体验,但通过包括游戏设计的其他八个元素(系统设计、设置、视觉设计、音乐和音频等)中的几个,就有可能将这种基本的交互转变为几乎无限种引人入胜的体验,并为玩家创造存在感。在接下来的章节中,你会在这些练习中加入更多的游戏设计元素。
资源地图和 着色器加载器项目、场景文件项目和场景对象项目旨在帮助您开始考虑从头开始构建游戏设计以获得最大效率,从而最大限度地减少诸如资产加载延迟等影响玩家存在感的问题。当你开始设计有多个阶段和关卡以及许多资源的游戏时,资源管理计划变得至关重要。了解可用内存的限制以及如何智能地加载和卸载资产可能意味着一次出色的体验和一次令人沮丧的体验之间的差异。
我们通过我们的感官体验世界,当我们加入额外的感官输入时,我们在游戏中的存在感往往会被放大。音频支持项目以恒定背景分数的形式将基本音频添加到我们从场景对象项目进行的简单状态改变练习中,以提供环境气氛,并包括两个区域中每个区域的不同运动声音。比较这两种体验,并考虑由于声音提示的存在,它们的感觉有多不同;虽然两者之间的视觉和交互体验是相同的,但由于背景音乐的节拍和矩形移动时产生的个体音调,音频支持项目开始添加一些情感线索。音频是互动体验的强大增强,可以极大地增加玩家在游戏环境中的存在感,当你继续阅读这些章节时,你将更详细地探索音频是如何为游戏设计做出贡献的。
五、使用纹理、精灵和字体
完成本章后,您将能够
-
使用任何图像或照片作为代表游戏中角色或物体的纹理
-
理解并使用纹理坐标来识别图像上的位置
-
通过将多个角色和对象合并到一个图像中来优化纹理内存利用率
-
使用 sprite 工作表制作和控制动画
-
在游戏中的任何地方显示不同字体和大小的文本
介绍
在大多数 2D 游戏中,定制的图像被用来表现几乎所有的物体,包括人物、背景,甚至是动画。因此,对图像操作的适当支持是 2D 游戏引擎的核心。游戏通常在三个不同的阶段处理图像:加载、渲染和卸载。
加载是将图像从网络服务器的硬盘读取到客户端的系统主内存中,并在图形子系统中进行处理和存储。渲染发生在游戏过程中,当载入的图像被连续绘制以代表各自的游戏对象。卸载发生在游戏不再需要某个图像,并且相关资源被回收以备将来使用的时候。由于硬盘的响应速度较慢,并且可能需要传输和处理大量数据,因此加载映像可能需要很长时间。此外,就像图像所代表的对象一样,图像的有用性通常与单个游戏级别相关联,图像加载和卸载操作通常发生在游戏级别转换期间。为了优化装载和卸载操作的数量,通常的做法是将多个较低分辨率的图像合并成一个较大的图像。这个较大的图像被称为子画面。
为了表现物体,带有有意义的图画的图像被粘贴,或者映射在简单的几何图形上。例如,游戏中的一匹马可以用一个用马的图像映射的正方形来表示。这样,一个游戏开发者就可以操纵方块的变换来控制马。图像在几何图形上的映射在计算机图形学中被称为纹理映射。
通过在同一几何体上有策略地映射选定的图像,可以创建运动的幻觉或动画。例如,在随后的游戏循环更新期间,可以将同一匹马的不同图像与策略性绘制的腿位置映射在同一方块上,以创建该马正在飞奔的假象。通常,这些不同动画位置的图像存储在一个 sprite 表或动画 sprite 表中。对这些图像进行排序以创建动画的过程被称为子画面动画或子画面动画。
本章首先向你介绍了纹理坐标的概念,这样你就可以理解并使用 WebGL 纹理映射接口进行编程。然后,您将构建一个核心纹理组件和相关类,以支持简单纹理的映射,处理包含多个对象的 sprite 工作表,创建和控制动画 sprite 工作表的运动,并从 sprite 工作表中提取字母字符以显示文本消息。
Note
纹理是加载到图形系统中并准备映射到几何图形的图像。当讨论纹理映射的过程时,“一个图像”和“一个纹理”经常互换使用。像素是图像中的颜色位置,而纹理元素是纹理中的颜色位置。
纹理映射和纹理坐标
如前所述,纹理映射是在几何体上粘贴图像的过程,就像在物体上贴标签一样。在您的游戏引擎中,您将创建 GLSL 着色器,从纹理中有策略地选择纹理元素,并在单位正方形所覆盖的屏幕像素位置显示相应的纹理元素颜色,而不是为单位正方形所占据的每个像素绘制恒定的颜色。选择一个纹理元素或将一组纹理元素转换为单一颜色以显示到屏幕像素位置的过程称为纹理采样。为了渲染纹理映射的像素,必须对纹理进行采样以提取相应的纹理元素颜色。
将任何分辨率的纹理映射到固定大小的几何体的过程都是令人望而生畏的。指定纹理空间的纹理坐标系被设计成隐藏纹理的分辨率,以便于该映射过程。如图 5-1 所示,纹理坐标系是定义在整个纹理上的归一化系统,原点位于左下角,而(1,1)位于右上角。不管分辨率如何,归一化的 0 到 1 范围总是在整个纹理上定义,这个简单的事实是纹理坐标系的优雅之处。给定任何分辨率的纹理,(0.5,0.5)总是中心,(0,1)总是左上角,依此类推。注意,在图 5-1 中,横轴标为 u 轴,纵轴标为 v 轴。纹理坐标或与纹理坐标相关联的 uv 值经常被互换使用来指代纹理坐标系中的位置。
图 5-1
为所有图像定义的纹理坐标系和相应的 uv 值
Note
有定义 v 轴向上或向下增加的惯例。在本书的所有例子中,你将编写 WebGL 来遵循图 5-1 中的约定,v 轴向上增加。
要将纹理映射到单位正方形上,必须为每个顶点位置定义相应的 uv 值。如图 5-2 所示,除了为正方形的四个角定义 xy 位置值外,为了将图像映射到该正方形上,还必须定义相应的 uv 坐标。在这种情况下,左上角有 xy=(-0.5,0.5)和 uv=(0,1),右上角有 xy=(0.5,0.5)和 uv=(1,1),以此类推。根据这一定义,可以通过对顶点处定义的 uv 值进行线性插值来计算正方形内任何位置的唯一 uv 值。例如,给定图 5-2 中所示的设置,你知道沿着正方形顶边的中点映射到纹理空间中的 uv(0.5,1.0),沿着左边缘的中点映射到 uv(0,0.5),等等。
图 5-2
定义纹理空间 uv 值,以将整个图像映射到模型空间中的几何图形上
纹理着色器项目
这个项目演示了用 WebGL 加载、渲染和卸载纹理。你可以在图 5-3 中看到这个项目运行的一个例子,左边和右边是两个场景的截图。请注意左边截图中没有白色边框的自然物体和右边截图中白色背景的图像。这个项目还将突出有和没有阿尔法通道,或透明度的图像之间的差异。这个项目的源代码在chapter5/5.1.texture_shaders
文件夹中定义。
图 5-3
对两个场景运行纹理着色器项目
对于这两个场景,项目的控件如下:
-
右箭头键:向右移动中间的矩形。如果这个矩形通过了窗口的右边界,它将被绕到窗口的左边。
-
左箭头键:向左移动中间的矩形。如果这个矩形越过左窗口边界,游戏将过渡到下一个场景。
该项目的目标如下:
-
演示如何使用 WebGL 定义几何图形的 uv 坐标
-
用 WebGL 在图形系统中创建纹理坐标缓冲区
-
构建 GLSL 着色器以渲染纹理几何体
-
定义
Texture
核心引擎组件,将图像加载并处理到纹理中,以及卸载纹理 -
为了实现简单的纹理着色,用程序员指定的颜色修改所有纹理元素
您可以在assets
文件夹中找到以下外部资源文件:一个场景级文件(blue_level.xml
)和四个图像(minion_collector.jpg
、minion_collector.png
、minion_portal.jpg
和minion_portal.png
)。
概观
创建和整合纹理涉及到相对重大的变化和新的类被添加到游戏引擎。以下概述介绍了这些变化的背景和原因:
-
texture_vs.glsl
和texture_fs.glsl
:这些是新创建的文件,用于定义 GLSL 着色器,以支持使用 uv 坐标绘制。回想一下,GLSL 着色器必须在游戏引擎初始化期间加载到 WebGL 中并进行编译。 -
vertex_buffer.js
:修改该文件,创建相应的 uv 坐标缓冲区,为单位正方形的顶点定义纹理坐标。 -
texture_shader.js
:这是一个新文件,它将TextureShader
定义为SimpleShader
的子类,以将游戏引擎与相应的 GLSL 着色器(TextureVS
和TextureFS
)接口。 -
texture_renderable.js
:这是一个新文件,它将TextureRenderable
定义为Renderable
的子类,以便于创建、操作和绘制纹理对象的多个实例。 -
shader_resources.js
:回想一下,这个文件定义了一个SimpleShader
实例来覆盖相应的 GLSL 着色器,由Renderable
对象的所有实例在系统范围内共享。以类似的方式,这个文件被修改以定义一个由所有TextureRenderable
对象的实例共享的TextureShader
实例。 -
gl.js
:修改该文件,配置 WebGL 支持纹理贴图绘制。 -
texture.js
:这是一个新文件,定义了能够加载、激活(用于渲染)和卸载纹理图像的核心引擎组件。 -
my_game.js
和blue_level.js
:这些游戏引擎客户端文件被修改以测试新的纹理映射功能。
创建两个新的源代码文件夹src/engine/shaders
和src/engine/renderables
,用于组织引擎源代码。创建这些文件夹是为了支持相应的纹理相关功能所需的许多新的着色器和渲染器类型。同样,持续的源代码重组对于支持复杂性的相应增加是很重要的。在维护和扩展大型软件系统的功能时,一个系统的和逻辑的源代码结构是至关重要的。
简单着色器/可渲染架构的扩展
回想一下,SimpleShader
/ Renderable
对象对被设计为支持将相关游戏引擎数据加载到SimpleVS/FS
GLSL 着色器,并支持游戏引擎客户端实例化Renderable
几何图形的多个副本。如图 5-4 所示,水*虚线将游戏引擎与 WebGL 分开。注意,GLSL 着色器SimpleVS
和SimpleFS
是 WebGL 中的模块,在游戏引擎之外。SimpleShader
对象维护对 GLSL 着色器中所有属性和统一变量的引用,并充当将所有变换和顶点信息发送到SimpleVS/FS
着色器的管道。虽然在图 5-4 中没有明确描述,但是在游戏引擎中只创建了一个SimpleShader
对象的实例,在shader_resources
中,并且这个实例由所有Renderable
对象共享。
图 5-4
SimpleShader
和Renderable
架构
纹理映射的正确支持需要新的 GLSL 顶点和片段着色器,因此需要在游戏引擎中定义相应的着色器和可渲染对象对。如图 5-5 所示,GLSL TextureVS
/ FS
着色器和TextureShader
/ TextureRenderable
对象对都是相应现有对象的扩展(或子类)。TextureShader
/ TextureRenderable
对象对从对应的SimpleShader
/ Renderable
对象扩展,将纹理坐标转发给 GLSL 着色器。TextureVS
/ FS
着色器是对相应的SimpleVS
/ FS
着色器的扩展,用于在计算像素颜色时从提供的纹理贴图中读取纹理元素。注意,因为 GLSL 不支持子类化,所以TextureVS
/ FS
源代码是从SimpleVS
/ FS
文件中复制的。
图 5-5
TextureVS/FS
GLSL 着色器和相应的TextureShader
/ TextureRenderable
对象对
GLSL 纹理明暗器
要支持使用纹理进行绘制,必须创建一个着色器,该着色器在每个顶点都接受几何(xy)和纹理(uv)坐标。您将通过复制和修改相应的SimpleVS
和SimpleFS
程序来创建新的 GLSL 纹理顶点和片段着色器。现在,您可以开始创建纹理顶点着色器。
-
在
src/glsl_shaders
文件夹中创建一个新文件,并将其命名为texture_vs.glsl
。 -
将以下代码添加到
texture_vs.glsl
文件中:
attribute vec3 aVertexPosition; // expects one vertex position
attribute vec2 aTextureCoordinate; // texture coordinate attribute
// texture coordinate that maps image to the square
varying vec2 vTexCoord;
// to transform the vertex position
uniform mat4 uModelXformMatrix;
uniform mat4 uCameraXformMatrix;
void main(void) {
// Convert the vec3 into vec4 for scan conversion and
// transform by uModelXformMatrix and uCameraXformMatrix before
// assign to gl_Position to pass the vertex to the fragment shader
gl_Position = uCameraXformMatrix *
uModelXformMatrix *
vec4(aVertexPosition, 1.0);
// pass the texture coordinate to the fragment shader
vTexCoord = aTextureCoordinate;
}
你可能会注意到TextureVS
着色器类似于SimpleVS
着色器,只增加了三行代码:
-
第一行添加了
aTextureCoordinate
属性。这定义了一个顶点,包括一个vec3
(aVertexPosition
,顶点的 xyz 位置)和一个vec2
(aTextureCoordinate
,顶点的 uv 坐标)。 -
第二个声明了
varying vTexCoord
变量。GLSL 中的关键字varying
表示相关变量将被线性插值并传递给片段着色器。如前所述,如图 5-2 所示,uv 值仅在顶点位置定义。在这种情况下,varying vTexCoord
变量指示图形硬件对 uv 值进行线性插值,以计算每次调用片段着色器的纹理坐标。 -
第三行也是最后一行将顶点 uv 坐标值指定给可变变量,用于插值和转发到片段着色器。
定义了顶点着色器后,现在可以创建关联的片段着色器:
-
在
src/glsl_shaders
文件夹中创建一个新文件,并将其命名为texture_fs.glsl
。 -
将以下代码添加到
texture_fs.glsl
文件中,以声明变量。sampler2D
数据类型是一个 GLSL 实用程序,能够从 2D 纹理中读取纹理元素值。在这种情况下,uSampler
对象将被绑定到一个 GLSL 纹理,这样就可以对渲染的每个像素进行纹理采样。uPixelColor
和SimpleFS
的一样。vTexCoord
是每个像素的插值 uv 坐标值。 -
添加以下代码来计算每个像素的颜色:
// The object that fetches data from texture.
// Must be set outside the shader.
uniform sampler2D uSampler;
// Color of pixel
uniform vec4 uPixelColor;
// "varying" keyword signifies that the texture coordinate will be
// interpolated and thus varies.
varying vec2 vTexCoord;
void main(void) {
// texel color look up based on interpolated UV value in vTexCoord
vec4 c = texture2D(uSampler, vec2(vTexCoord.s, vTexCoord.t));
// tint the textured. transparent area defined by the texture
vec3 r = vec3(c) * (1.0-uPixelColor.a) +
vec3(uPixelColor) * uPixelColor.a;
vec4 result = vec4(r, c.a);
gl_FragColor = result;
}
texture2D()
函数使用来自vTexCoord
的插值 uv 值从与uSampler
相关联的纹理中采样并读取纹理元素值。在这个例子中,根据透明度或相应 alpha 通道的值,通过uPixelColor
中定义的颜色值的加权和来修改或着色纹理元素的颜色。总的来说,对纹理颜色的着色没有一致的定义。你可以自由地尝试不同的方法来组合uPixelColor
和采样的纹理颜色。例如,您可以尝试将两者相乘。在提供的源代码文件中,建议了一些替代方法。请用它们做实验。
定义和设置纹理坐标
回想一下,所有着色器共享在vertex_buffer.js
文件中定义的单位正方形的相同 xy 坐标缓冲区。以类似的方式,必须定义相应的缓冲区来为 GLSL 着色器提供纹理坐标。
-
修改
vertex_buffer.js
为单位正方形定义 xy 和 uv 坐标。如图 5-2 所示,mTextureCoordinates
变量定义了mVerticesOfSquare
中依次定义的单位正方形对应的四个 xy 值的 uv 值。例如,(1,1)是与(0.5,0.5,0) xy 位置相关联的 uv 值,(0,1)表示(-0.5,0.5,0),依此类推。 -
定义变量
mGLTextureCoordBuffer
,为mTextureCoordinates
的纹理坐标值和相应的 getter 函数保存对 WebGL 缓冲存储的引用:
// First: define the vertices for a square
let mVerticesOfSquare = [
0.5, 0.5, 0.0,
-0.5, 0.5, 0.0,
0.5, -0.5, 0.0,
-0.5, -0.5, 0.0
];
// Second: define the corresponding texture coordinates
let mTextureCoordinates = [
1.0, 1.0,
0.0, 1.0,
1.0, 0.0,
0.0, 0.0
];
- 修改
init()
函数,加入步骤 D,将纹理坐标初始化为 WebGL 缓冲区。注意,初始化过程与顶点 xy 坐标的初始化过程相同,除了对新缓冲区的引用存储在mGLTextureCoordBuffer
中,传输的数据是 uv 坐标值。
let mGLTextureCoordBuffer = null;
function getTexCoord() { return mGLTextureCoordBuffer; }
- 记得在最终清理期间释放分配的缓冲区:
function init() {
let gl = glSys.get();
... identical to previous code ...
// Step D: Allocate and store texture coordinates
// Create a buffer on the gl context for texture coordinates
mGLTextureCoordBuffer = gl.createBuffer();
// Activate texture coordinate buffer
gl.bindBuffer(gl.ARRAY_BUFFER, mGLTextureCoordBuffer);
// Loads textureCoordinates into the mGLTextureCoordBuffer
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array(mTextureCoordinates), gl.STATIC_DRAW);
}
- 最后,记得导出更改:
function cleanUp() {
... identical to previous code ...
if (mGLTextureCoordBuffer !== null) {
gl.deleteBuffer(mGLTextureCoordBuffer);
mGLTextureCoordBuffer = null;
}
}
export {init, cleanUp, get, getTexCoord}
将 GLSL 着色器接口到引擎
正如SimpleShader
对象被定义为与SimpleVS
和SimpleFS
着色器接口一样,相应的着色器对象需要在游戏引擎中创建,以与TextureVS
和TextureFS
GLSL 着色器接口。如本项目概述中所述,您还将创建一个新文件夹来组织不断增加的不同着色器。
-
在
src/engine
中新建一个名为shaders
的文件夹。将simple_shader.js
文件移动到这个文件夹中,不要忘记更新index.js
中的参考路径。 -
在
src/engine/shaders
文件夹中创建一个新文件,并将其命名为texture_shader.js
。
class TextureShader extends SimpleShader {
constructor(vertexShaderPath, fragmentShaderPath) {
// Call super class constructor
super(vertexShaderPath, fragmentShaderPath);
// reference to aTextureCoordinate within the shader
this.mTextureCoordinateRef = null;
// get the reference of aTextureCoordinate within the shader
let gl = glSys.get();
this.mTextureCoordinateRef = gl.getAttribLocation(
this.mCompiledShader,
"aTextureCoordinate");
this.mSamplerRef = gl.getUniformLocation(this.mCompiledShader,
"uSampler");
}
... implementation to follow ...
在列出的代码中,请注意以下几点:
-
覆盖
activate()
功能以启用纹理坐标数据。超类super.activate()
函数设置 xy 顶点位置,并将pixelColor
、trsMatrix
和cameraMatrix
的值传递给着色器。其余代码将mTextureCoordinateRef
(在vertex_buffer
模块中定义的纹理坐标缓冲区)绑定到 GLSL 着色器中的aTextureCoordinate
属性,并将mSampler
绑定到纹理单元 0(稍后将详细描述)。 -
定义的
TextureShader
类是SimpleShader
类的扩展或子类。 -
构造函数实现首先调用
SimpleShader
的构造函数super()
。回想一下,SimpleShader
构造函数将加载并编译由vertexShaderPath
和fragmentShaderPath
参数定义的 GLSL 着色器,并设置mVertexPositionRef
来引用着色器中定义的aVertexPosition
属性。 -
在构造函数的其余部分中,
mTextureCoordinateRef
保持对在texture_vs.glsl
中定义的aTextureCoordinate
属性的引用。 -
这样,顶点位置(
aVertexPosition
)和纹理坐标(aTextureCoordinate
)属性都被一个 JavaScriptTextureShader
对象引用。
// Overriding the Activation of the shader for rendering
activate(pixelColor, trsMatrix, cameraMatrix) {
// first call the super class's activate
super.activate(pixelColor, trsMatrix, cameraMatrix);
// now our own functionality: enable texture coordinate array
let gl = glSys.get();
gl.bindBuffer(gl.ARRAY_BUFFER, this._getTexCoordBuffer());
gl.vertexAttribPointer(this.mTextureCoordinateRef, 2,
gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(this.mTextureCoordinateRef);
// bind uSampler to texture 0
gl.uniform1i(this.mSamplerRef, 0);
// texture.activateTexture() binds to Texture0
}
通过组合SimpleShader
和TextureShader
的功能,在activate()
函数调用之后,GLSL texture_vs
着色器中的两个属性变量(aVertexPosition
和aTextureCoordinate
)都被连接到 WebGL 内存中相应的缓冲区。
使用 shader_resources 促进共享
与SimpleShader
是可重用资源一样,只需要创建TextureShader
的一个实例,并且这个实例可以共享。应该修改shader_resources
模块来反映这一点。
-
在
shader_resources.js
中,添加变量以保存纹理着色器: -
定义一个函数来检索纹理着色器:
// Texture Shader
let kTextureVS = "src/glsl_shaders/texture_vs.glsl"; // VertexShader
let kTextureFS = "src/glsl_shaders/texture_fs.glsl"; // FragmentShader
let mTextureShader = null;
- 在
createShaders()
函数中创建纹理着色器的实例:
function getTextureShader() { return mTextureShader; }
- 修改
init()
函数以追加loadPromise
来包含纹理着色器源文件的加载:
function createShaders() {
mConstColorShader = new SimpleShader(kSimpleVS, kSimpleFS);
mTextureShader = new TextureShader(kTextureVS, kTextureFS);
}
- 记得在清理期间释放新分配的资源:
function init() {
let loadPromise = new Promise(
async function(resolve) {
await Promise.all([
text.load(kSimpleFS),
text.load(kSimpleVS),
text.load(kTextureFS),
text.load(kTextureVS)
]);
resolve();
}).then(
function resolve() { createShaders(); }
);
map.pushPromise(loadPromise);
}
- 最后,记住导出新定义的功能:
function cleanUp() {
mConstColorShader.cleanUp();
mTextureShader.cleanUp();
text.unload(kSimpleVS);
text.unload(kSimpleFS);
text.unload(kTextureVS);
text.unload(kTextureFS);
}
export {init, cleanUp, getConstColorShader, getTextureShader}
TextureRenderable 类
就像Renderable
类封装并方便了SimpleShader
对象多个实例的定义和绘制一样,需要定义一个对应的TextureRenderable
类来支持TextureShader
对象多个实例的绘制。
对可渲染类的更改
正如项目概述中提到的,由于在Shaders
文件夹中创建和组织着色器类的原因,应该创建一个renderables
文件夹来组织不断增长的不同种类的Renderable
对象。此外,必须修改Renderable
类以支持它成为所有Renderable
对象的基类。
-
创建
src/engine/renderables
文件夹并将renderable.js
移动到该文件夹中。记得更新index.js
以反映文件位置的变化。 -
定义
_setShader()
函数来设置Renderable
的着色器。这是一个受保护的函数,允许子类修改mShader
变量,以引用每个相应子类的适当着色器。
// this is private/protected
_setShader(s) { this.mShader = s; }
Note
名称以“_”开头的函数是私有的或受保护的,不应从类外部调用。这是本书遵循的惯例,并不是由 JavaScript 强制执行的。
定义 TextureRenderable 类
您现在已经准备好定义TextureRenderable
类了。如前所述,TextureRenderable
是从Renderable
类派生而来的,并扩展了其渲染纹理映射对象的功能。
-
在
src/engine/renderables
文件夹中创建一个新文件,并将其命名为texture_renderable.js
。添加构造函数。回想一下,super()
是对超类(Renderable
)构造函数的调用;类似地,super.setColor()
和super._setShader()
是对超类函数的调用。正如将在讨论引擎texture
资源模块时详细描述的那样,myTexture
参数是包含纹理图像的文件的路径。 -
定义一个
draw()
函数来附加在Renderable
类中定义的函数以支持纹理。texture.activate()
功能激活并允许用特定纹理绘图。该功能的细节将在下一节讨论。
class TextureRenderable extends Renderable {
constructor(myTexture) {
super();
super.setColor([1, 1, 1, 0]); // Alpha 0: no texture tinting
super._setShader(shaderResources.getTextureShader());
this.mTexture = myTexture; // cannot be a "null"
}
... implementation to follow ...
- 为纹理引用定义一个 getter 和 setter:
draw(camera) {
// activate the texture
texture.activate(this.mTexture);
super.draw(camera);
}
- 最后,记得导出类:
getTexture() { return this.mTexture; }
setTexture(newTexture) { this.mTexture = newTexture; }
export default TextureRenderable;
引擎中的纹理支持
为了支持使用纹理进行绘制,游戏引擎的其余部分需要进行两项主要修改:WebGL 上下文配置和一个专用引擎组件,以支持与纹理相关的操作。
配置 WebGL 以支持纹理
必须更新 WebGL 上下文的配置以支持纹理。在gl.js
中,根据以下内容更新init()
功能:
function init(htmlCanvasID) {
mCanvas = document.getElementById(htmlCanvasID);
if (mCanvas == null)
throw new Error("Engine init [" +
htmlCanvasID + "] HTML element id not found");
// the standard or experimental webgl and binds to the Canvas area
// store the results to the instance variable mGL
mGL = mCanvas.getContext("webgl2", {alpha: false}) ||
mCanvas.getContext("experimental-webgl2", {alpha: false});
if (mGL === null) {
document.write("<br><b>WebGL 2 is not supported!</b>");
return;
}
// Allows transparency with textures.
mGL.blendFunc(mGL.SRC_ALPHA, mGL.ONE_MINUS_SRC_ALPHA);
mGL.enable(mGL.BLEND);
// Set images to flip y axis to match the texture coordinate space.
mGL.pixelStorei(mGL.UNPACK_FLIP_Y_WEBGL, true);
}
传递给mCanvas.getContext()
的参数通知浏览器画布应该是不透明的。这可以加速透明内容和图像的绘制。使用 alpha 通道绘制图像时,blendFunc()
功能启用透明。pixelStorei()
函数将 uv 坐标的原点定义在左下角。
创建纹理资源模块
与文本和音频文件类似,必须定义新的引擎组件来支持相应的纹理操作,包括从服务器文件系统加载、通过 WebGL 上下文存储到 GPU 内存、激活用于绘制的纹理缓冲区以及从 GPU 中移除:
-
在
src/engine/resources
文件夹中创建一个新文件,并将其命名为texture.js
。这个文件将实现Texture
引擎组件。 -
定义
TextureInfo
类来表示游戏引擎中的纹理。mWidth
和mHeight
是纹理图像的像素分辨率,mGLTexID
是对 WebGL 纹理存储的引用。
class TextureInfo {
constructor(w, h, id) {
this.mWidth = w;
this.mHeight = h;
this.mGLTexID = id;
}
}
Note
出于效率的考虑,很多图形硬件只支持图像分辨率为 2 的幂次的纹理,比如 2x4(21x22,或者 4x 16(22x24,或者 64x 256(26x28等等。WebGL 也是这种情况。本书中的所有例子只适用于分辨率为 2 次方的纹理。
-
从
resource_map
导入核心资源管理功能: -
定义一个函数来异步加载一个图像作为承诺,并将该承诺作为
map
中待定承诺的一部分。与文本和音频资源不同,JavaScriptImage
API 支持直接的图像文件加载,在这种情况下不需要map.loadDecodeParse()
。一旦图像被加载,它就被传递给processLoadedImage()
函数,并以其文件路径作为名称。
import * as map from "../core/resource_map.js";
// functions from resource_map
let has = map.has;
let get = map.get;
- 增加一个
unload()
函数来清理引擎,释放 WebGL 资源:
// Loads a texture so that it can be drawn.
function load(textureName) {
let image = new Image();
let texturePromise = new Promise(
function(resolve) {
image.onload = resolve;
image.src = textureName;
}).then(
function resolve() {
processLoadedImage(textureName, image); }
);
map.pushPromise(texturePromise);
return texturePromise;
}
- 现在定义
processLoadedImage()
函数来转换图像的格式,并将其存储到 WebGL 上下文中。gl.createTexture()
函数创建一个 WebGL 纹理缓冲区并返回一个唯一的 ID。texImage2D()
函数将图像存储到 WebGL 纹理缓冲区,而generateMipmap()
为纹理计算一个 mipmap。最后,实例化一个TextureInfo
对象来引用 WebGL 纹理,并根据纹理图像文件的文件路径存储到resource_map
中。
// Remove the reference to allow associated memory
// to be available for subsequent garbage collection
function unload(textureName) {
let texInfo = get(textureName);
if (map.unload(textureName)) {
let gl = glSys.get();
gl.deleteTexture(texInfo.mGLTexID);
}
}
function processLoadedImage(path, image) {
let gl = glSys.get();
// Generate a texture reference to the webGL context
let textureID = gl.createTexture();
// binds texture reference with current texture in the webGL
gl.bindTexture(gl.TEXTURE_2D, textureID);
// Loads texture to texture data structure with descriptive info.
// Parameters:
// 1: "binding point" or target the texture is being loaded to.
// 2: Level of detail. Used for mipmapping. 0 is base texture level.
// 3: Internal format. The composition of each element. i.e. pixels.
// 4: Format of texel data. Must match internal format.
// 5: The data type of the texel data.
// 6: Texture Data.
gl.texImage2D(gl.TEXTURE_2D, 0,
gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
// Creates a mipmap for this texture.
gl.generateMipmap(gl.TEXTURE_2D);
// Tells WebGL done manipulating data at the mGL.TEXTURE_2D target.
gl.bindTexture(gl.TEXTURE_2D, null);
let texInfo = new TextureInfo(image.naturalWidth,
image.naturalHeight, textureID);
map.set(path, texInfo);
}
Note
一个小中见大贴图是便于高质量渲染的纹理图像的表示。请查阅计算机图形学参考书,了解更多关于纹理贴图表示和相关纹理贴图算法的信息。
-
定义一个函数来激活用于绘图的 WebGL 纹理:
-
get()
函数根据textureName
从resource_map
中定位TextureInfo
对象。在bindTexture()
函数中使用定位的mGLTexID
来激活相应的 WebGL 纹理缓冲区进行渲染。 -
texParameteri()
函数定义了纹理的渲染行为。TEXTURE_WRAP_S/T
参数确保纹理元素值不会在纹理边界处绕回。TEXTURE_MAG_FILTER
参数定义了如何放大一个纹理,换句话说,当一个低分辨率的纹理被渲染到游戏窗口中的许多像素时。TEXTURE_MIN_FILTER
参数定义了如何最小化一个纹理,换句话说,当一个高分辨率纹理被渲染到少量像素时。 -
LINEAR
和LINEAR_MIPMAP_LINEAR
配置通过模糊原始图像的细节来生成*滑纹理,而注释掉的NEAREST
选项将产生最适合像素化效果的未处理纹理。请注意,在这种情况下,纹理图像的颜色边界可能会呈现锯齿状。
function activate(textureName) {
let gl = glSys.get();
let texInfo = get(textureName);
// Binds texture reference to the current webGL texture functionality
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texInfo.mGLTexID);
// To prevent texture wrapping
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Handles how magnification and minimization filters will work.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR);
// For the texture to look "sharp" do the following:
// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER,gl.NEAREST);
// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,gl.NEAREST);
}
Note
一般来说,最好使用分辨率与游戏中物体所占像素数量相*的纹理图像。例如,占据 64x64 像素空间的正方形应该理想地使用 64x64 纹理。
-
如下定义停用纹理的函数。该函数将 WebGL 上下文设置为不使用任何纹理的状态。
-
最后,记得导出功能:
function deactivate() {
let gl = glSys.get();
gl.bindTexture(gl.TEXTURE_2D, null);
}
export {has, get, load, unload,
TextureInfo,
activate, deactivate}
将新功能导出到客户端
将纹理功能集成到引擎中的最后一步涉及修改引擎访问文件index.js
。编辑index.js
并添加以下导入和导出语句,以授予客户端对texture
资源模块和TextureRenderable
类的访问权限:
... identical to previous code ...
import * as texture from "./resources/texture.js";
// renderables
import Renderable from "./renderables/renderable.js";
import TextureRenderable from "./renderables/texture_renderable.js";
... identical to previous code ...
export default {
// resource support
audio, text, xml, texture,
// input support
input,
// Util classes
Camera, Scene, Transform,
// Renderables
Renderable, TextureRenderable,
// functions
init, cleanUp, clearCanvas
}
纹理映射功能的测试
通过所描述的修改,游戏引擎现在可以渲染恒定颜色的对象以及具有有趣和不同类型纹理的对象。下面的测试代码类似于前面的例子,其中两个场景MyGame
和BlueLevel
用于演示新添加的纹理映射功能。主要修改包括纹理图像的加载和卸载以及TextureRenderable
对象的创建和绘制。此外,MyGame
场景使用 PNG 图像高亮显示带有 alpha 通道的透明纹理贴图,BlueScene
场景使用 JPEG 格式的图像显示相应的纹理。
正如在所有构建游戏的案例中一样,确保所有外部资源都得到合理组织是至关重要的。回想一下,assets
文件夹是专门为组织外部资源而创建的。注意位于assets
文件夹中的四个新纹理文件:minion_collector.jpg
、minion_collector.png
、minion_portal.jpg
和minion_portal.png
。
修改蓝色级别场景文件以支持纹理
blue_level.xml
场景文件修改自上一个例子,以支持纹理映射:
<MyGameLevel>
<!-- cameras -->
<!-- Viewport: x, y, w, h -->
<Camera CenterX="20" CenterY="60" Width="20"
Viewport="20 40 600 300"
BgColor="0 0 1 1.0"/>
<!-- The red rectangle -->
<Square PosX="20" PosY="60" Width="2" Height="3"
Rotation="0" Color="1 0 0 1" />
<!-- Textures Square -->
<TextureSquare PosX="15" PosY="60" Width="3" Height="3"
Rotation="-5" Color="1 0 0 0.3"
Texture="assets/minion_portal.jpg" />
<TextureSquare PosX="25" PosY="60" Width="3" Height="3"
Rotation="5" Color="0 0 0 0"
Texture="assets/minion_collector.jpg"/>
<!-- without tinting, alpha should be 0 -->
</MyGameLevel>
元素TextureSquare
类似于Square
,增加了一个Texture
属性,指定哪个图像文件应该被用作正方形的纹理贴图。注意,正如在texture_fs.glsl
中实现的那样,Color
元素的 alpha 值被用来给纹理贴图着色。XML 场景描述旨在支持minion_portal.jpg
纹理的轻微着色和minion_collector.jpg
纹理的不着色。这种纹理着色效果可以在图 5-3 的右图中观察到。此外,请注意,指定的两个图像都是 JPEG 格式。由于 JPEG 格式不支持 alpha 通道的存储,两幅图像的未使用区域在图 5-3 的右图中显示为门户和 collector minions 外部的白色区域。
修改 SceneFileParser
修改场景文件解析器scene_file_parser.js
,支持解析更新后的blue_scene.xml
,特别是将Square
元素解析为Renderable
对象,将TextureSquare
元素解析为TextureRenderable
对象。变更详情请参考src/my_game/util
文件夹下的源代码文件。
用 JPEGs 测试 BlueLevel
对blue_level.js
的修改是在构造函数、load()
、unload()
、next()
和init()
函数中,在这些函数中加载和卸载纹理图像并解析新的TextureRenderable
对象:
-
编辑
blue_level.js
并修改构造函数以定义常数来表示纹理图像: -
在
load()
功能中启动纹理加载:
class BlueLevel extends engine.Scene {
constructor() {
super();
// scene file name
this.kSceneFile = "assets/blue_level.xml";
// textures: (Note: jpg does not support transparency)
this.kPortal = "assets/minion_portal.jpg";
this.kCollector = "assets/minion_collector.jpg";
// all squares
this.mSqSet = []; // these are the Renderable objects
// The camera to view the scene
this.mCamera = null;
}
... implementation to follow ...
- 同样,通过在
unload()
函数中卸载纹理来添加代码进行清理:
load() {
// load the scene file
engine.xml.load(this.kSceneFile);
// load the textures
engine.texture.load(this.kPortal);
engine.texture.load(this.kCollector);
}
- 支持用
next()
功能加载下一个场景:
unload() {
// unload the scene file and loaded resources
engine.xml.unload(this.kSceneFile);
engine.texture.unload(this.kPortal);
engine.texture.unload(this.kCollector);
}
- 解析
init()
函数中的纹理方块:
next() {
super.next();
let nextLevel = new MyGame(); // load the next level
nextLevel.start();
}
- 在
update()
函数中包含适当的代码,以连续改变门户TextureRenderable
的着色,如下所示:
init() {
let sceneParser = new SceneFileParser(this.kSceneFile);
// Step A: Read in the camera
this.mCamera = sceneParser.parseCamera();
// Step B: Read all the squares and textureSquares
sceneParser.parseSquares(this.mSqSet);
sceneParser.parseTextureSquares(this.mSqSet);
}
-
mSqSet
的索引 1 是门户TextureRenderable
对象,颜色数组的索引 3 是 alpha 通道。 -
列出的代码不断增加并包装
TextureRenderable
对象中mColor
变量的 alpha 值。回想一下,这个变量的值被传递给TextureShader
,然后被加载到TextureFS
的uPixelColor
来着色纹理贴图的结果。 -
正如在
blue_scene.xml
文件的第一个TextureSquare
元素中所定义的,为门户对象定义的颜色是红色。由于这个原因,当运行这个项目时,在蓝色级别中,门户对象以红色闪烁。
update() {
... identical to previous code ...
// continuously change texture tinting
let c = this.mSqSet[1].getColor();
let ca = c[3] + deltaX;
if (ca > 1) {
ca = 0;
}
c[3] = ca;
}
用 PNGs 测试我的游戏
与BlueLevel
场景相似,MyGame
是对上一个例子的简单修改,修改后载入和卸载纹理图像,并创建TextureRenderable
对象:
-
编辑
my_game.js
;修改MyGame
构造函数,定义纹理图像文件和引用TextureRenderable
对象的变量; -
在
load()
功能中启动纹理加载:
class MyGame extends engine.Scene {
constructor() {
super();
// textures:
this.kPortal = "assets/minion_portal.png"; // with transparency
this.kCollector = "assets/minion_collector.png";
// The camera to view the scene
this.mCamera = null;
// the hero and the support objects
this.mHero = null;
this.mPortal = null;
this.mCollector = null;
}
- 确保你记得卸载
unload()
中的纹理:
load() {
// loads the textures
engine.texture.load(this.kPortal);
engine.texture.load(this.kCollector);
}
- 定义
next()
函数启动蓝色电*:
unload() {
// Game loop not running, unload all assets
engine.texture.unload(this.kPortal);
engine.texture.unload(this.kCollector);
}
- 在
init()
函数中创建并初始化TextureRenderables
对象:
next() {
super.next();
// starts the next level
let nextLevel = new BlueLevel(); // next level to be loaded
nextLevel.start();
}
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(20, 60), // position of the camera
20, // width of camera
[20, 40, 600, 300] // viewport (X, Y, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
// Step B: Create the game objects
this.mPortal = new engine.TextureRenderable(this.kPortal);
this.mPortal.setColor([1, 0, 0, 0.2]); // tints red
this.mPortal.getXform().setPosition(25, 60);
this.mPortal.getXform().setSize(3, 3);
this.mCollector = new engine.TextureRenderable(this.kCollector);
this.mCollector.setColor([0, 0, 0, 0]); // No tinting
this.mCollector.getXform().setPosition(15, 60);
this.mCollector.getXform().setSize(3, 3);
// Step C: Create the hero object in blue
this.mHero = new engine.Renderable();
this.mHero.setColor([0, 0, 1, 1]);
this.mHero.getXform().setPosition(20, 60);
this.mHero.getXform().setSize(2, 3);
}
记住纹理文件路径被用作resource_map
中的唯一标识符。因此,文件纹理加载和卸载以及TextureRenderable
对象的创建必须引用相同的文件路径。在给定的代码中,所有三个函数都引用构造函数中定义的相同常数。
- 对
draw()
函数的修改通过调用相应的draw()
函数来绘制两个新的TextureRenderable
对象,而对update()
函数的修改与前面讨论的BlueLevel
类似。详情请参考src/my_game
文件夹中的my_game.js
源代码文件。
当在chapter5/5.1.texture_shaders
文件夹中运行这个项目的例子时,再次注意连续改变纹理着色的结果——门户小程序以红色闪烁。此外,请注意MyGame
级别中基于 PNG 的纹理和BlueLevel
中对应的带白色边框的 JPEG 纹理之间的差异。使用带有 alpha(或透明度)通道的纹理来表示对象在视觉上更令人愉快和准确。PNG 是支持 alpha 通道的最流行的图像格式之一。
观察
这个项目是你合作过的时间最长、最复杂的一个项目。这是因为使用纹理映射需要您理解纹理坐标,实现跨越引擎中的许多文件,并且实际的图像必须被加载、转换成纹理并通过 WebGL 存储/访问。为了帮助总结这些变化,图 5-6 显示了与用于纹理映射的图像状态和一些主要游戏引擎操作相关的游戏引擎状态。
图 5-6 的左栏标识了主要的游戏引擎状态,从 WebGL 初始化到场景初始化,再到游戏循环,最后到场景卸载。中间一列显示了将被用作纹理的图像的相应状态。最初,这个映像存储在服务器文件系统中。在场景初始化期间,Scene.load()
函数将调用engine/resources/texture.load()
函数加载图像,并使加载的图像由engine/resources/texture.processLoadedImage()
函数处理成相应的 WebGL 纹理,存储在 GPU 纹理缓冲区中。在游戏循环周期中,TextureRenderable.draw()
功能通过engine/resources/texture.activate()
功能激活适当的 WebGL 纹理。这使得相应的 GLSL 片段着色器能够在渲染过程中从正确的纹理中采样。最后,当游戏不再需要某个纹理时,Scene.unload()
函数会调用engine/resources/texture.unload()
将加载的图像从系统中移除。
图 5-6
图像文件和相应 WebGL 纹理的状态概述
用 Sprite 工作表绘图
如前所述,sprite sheet 是由多个分别代表不同对象的低分辨率图像组成的图像。这些单独的图像中的每一个都被称为 sprite sheet 元素。例如,图 5-7 是一个 sprite 表,有来自 4 个不同对象的 13 个元素。上面两行的每一行都包含同一对象在不同动画位置的五个元素,在最后一行中,有不同对象的三个元素:角色染料、门户小程序和收集器小程序。创建子画面的艺术家或软件程序必须将每个子画面元素的像素位置传达给游戏开发者,就像图 5-7 所示的一样。
图 5-7
示例 sprite sheet: minion_sprite.png
由不同对象的低分辨率图像组成
定义子画面表是为了优化内存和处理需求。例如,回想一下,WebGL 只支持由分辨率为 2x×2y的图像定义的纹理。这个要求意味着分辨率为 120x180 的染料字符必须存储在 128x256 (2 7 × 2 8 )图像中,以便将其创建为 WebGL 纹理。此外,如果图 5-7 的 13 个元素被存储为单独的图像文件,那么这将意味着需要 13 个缓慢的文件系统访问来加载所有的图像,而不是一个单一的系统访问来加载子画面。
使用 sprite sheet 和相关元素的关键是要记住,不管实际的图像分辨率如何,纹理坐标 uv 值都是在 0 到 1 的归一化范围内定义的。例如,图 5-8 关注图 5-7 中收集器 minion 的 uv 值,第三行最右边的 sprite 元素。图 5-8 的顶行、中间行和底行显示了入口元素的坐标值。
图 5-8
从像素位置到 uv 值的坐标转换,用于在几何体上贴图
-
像素位置:左下角为(315,0),右上角为(495,180)。
-
UV 值:左下角为(0.308,0.0),右上角为(0.483,0.352)。
-
在 模型空间中使用:元素的纹理映射是通过将对应的 uv 值与每个顶点位置的 xy 值相关联来完成的。
精灵着色器项目
这个项目演示了如何通过定义适当的抽象和类来绘制带有 sprite sheet 元素的对象。你可以在图 5-9 中看到这个项目运行的例子。这个项目的源代码在chapter5/5.2.sprite_shaders
文件夹中定义。
图 5-9
运行精灵着色器项目
该项目的控制措施如下:
-
右箭头键:向右移动染色角色(英雄)并在到达右边界时循环到左边界
-
左箭头键:向左移动英雄,当到达左边界时,将位置重置到窗口的中间
该项目的目标如下:
-
为了更深入地理解纹理坐标
-
体验在图像中为纹理映射定义子区域
-
通过映射 sprite sheet 元素来绘制正方形
-
准备使用精灵动画和位图字体
您可以在assets
文件夹中找到以下外部资源文件:consolas-72.png
和minion_sprite.png
。注意minion_sprite.png
是图 5-7 所示的图像。
如图 5-5 所示,上一节定义的纹理支持的主要优缺点之一是通过getTexCoord()
函数访问的纹理坐标是在vertex_buffer.js
文件中静态定义的。这是一个优势,因为在整个图像被映射到一个正方形的情况下,所有TextureShader
对象的实例可以共享相同的默认 uv 值。这也是一个缺点,因为静态纹理坐标缓冲区不允许处理图像的不同子区域,因此不支持处理 sprite sheet 元素。如图 5-10 所示,本节中的示例通过在SpriteShader
和SpriteRenderable
对象中定义每对象纹理坐标来克服这一缺点。请注意,没有定义新的 GLSL 着色器,因为它们的功能与TextureVS/FS
相同。
图 5-10
在SpriteShader
中定义纹理坐标缓冲区
使用 SpriteShader 将 GLSL 纹理着色器连接到引擎
支持带有 sprite sheet 元素的纹理映射的着色器必须能够识别图像的不同子区域。为了支持这个功能,你将实现SpriteShader
来定义它自己的纹理坐标。由于这个新的着色器扩展了TextureShader
的功能,将它实现为子类是合乎逻辑的。
-
在
src/engine/shaders
文件夹中创建一个新文件,并将其命名为sprite_shader.js
。 -
定义
SpriteShader
类及其构造函数来扩展TextureShader
类:
class SpriteShader extends TextureShader {
constructor(vertexShaderPath, fragmentShaderPath) {
// Call super class constructor
super(vertexShaderPath, fragmentShaderPath);
this.mTexCoordBuffer = null; // gl buffer with texture coordinate
let initTexCoord = [
1.0, 1.0,
0.0, 1.0,
1.0, 0.0,
0.0, 0.0
];
let gl = glSys.get();
this.mTexCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.mTexCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array(initTexCoord), gl.DYNAMIC_DRAW);
// DYNAMIC_DRAW: says buffer content may change!
}
... implementation to follow ...
SpriteShader
在 WebGL 中定义自己的纹理坐标缓冲区,对这个缓冲区的引用由mTexCoordBuffer
保存。注意,当在 WebGL bufferData()
函数中创建这个缓冲区时,指定了DYNAMIC_DRAW
选项。当定义系统默认纹理坐标缓冲时,这与vertex_buffer.js
中使用的STATIC_DRAW
选项相比较。动态选项通知 WebGL 图形系统,该缓冲区的内容将发生变化。
- 定义一个函数来设置 WebGL 纹理坐标缓冲区:
setTextureCoordinate(texCoord) {
let gl = glSys.get();
gl.bindBuffer(gl.ARRAY_BUFFER, this.mTexCoordBuffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array(texCoord));
}
注意,texCoord
参数是一个 8 个浮点数的数组,指定了 WebGL 上下文的纹理坐标位置。这个数组的格式和内容由 WebGL 接口定义为右上角、左上角、右下角和左下角。在您的例子中,这些应该是 sprite sheet 元素的四个角。
-
覆盖纹理坐标访问函数
_getTexCoordBuffer()
,这样当着色器被激活时,返回本地分配的动态缓冲区,而不是全局静态缓冲区。注意,activate()
函数是从TextureShader
继承来的。 -
记得导出类:
_getTexCoordBuffer() {
return this.mTexCoordBuffer;
}
export default SpriteShader;
sprite 可呈现类
类似于Renderable
类(用SimpleShader
加阴影)和TextureRenderable
类(用TextureShader
加阴影),应该定义一个相应的SpriteRenderable
类来表示将用SpriteShader
加阴影的对象:
-
在
src/engine/renderables
文件夹中创建一个新文件,并将其命名为sprite_renderable.js
。 -
定义从
TextureRenderable
类扩展的SpriteRenderable
类和构造函数。请注意,四个实例变量mElmLeft
、mElmRight
、mElmTop
和mElmBottom
一起标识了纹理空间内的一个子区域。这些是 sprite sheet 元素的边界。 -
用标识 WebGL 纹理坐标规范数组的相应偏移位置的值定义枚举数据类型:
class SpriteRenderable extends TextureRenderable {
constructor(myTexture) {
super(myTexture);
super._setShader(shaderResources.getSpriteShader());
// sprite coordinate
this.mElmLeft = 0.0; // texture coordinate bound
this.mElmRight = 1.0; // 0-left, 1-right
this.mElmTop = 1.0; // 1-top 0-bottom
this.mElmBottom = 0.0; // of image
}
... implementation to follow ...
// texture coordinate array is an array of 8 floats where elements:
// [0] [1]: is u/v coordinate of Top-Right
// [2] [3]: is u/v coordinate of Top-Left
// [4] [5]: is u/v coordinate of Bottom-Right
// [6] [7]: is u/v coordinate of Bottom-Left
const eTexCoordArrayIndex = Object.freeze({
eLeft: 2,
eRight: 0,
eTop: 1,
eBottom: 5
});
Note
枚举数据类型的名称以“e”开头,如eTexCoordArrayIndex
。
- 定义函数以允许在纹理坐标空间(在 0 和 1 之间标准化)和像素位置(转换为 uv 值)中为 sprite sheet 元素指定 uv 值:
// specify element region by texture coordinate (between 0 to 1)
setElementUVCoordinate(left, right, bottom, top) {
this.mElmLeft = left;
this.mElmRight = right;
this.mElmBottom = bottom;
this.mElmTop = top;
}
// element region defined pixel positions (0 to image resolutions)
setElementPixelPositions(left, right, bottom, top) {
let texInfo = texture.get(this.mTexture);
// entire image width, height
let imageW = texInfo.mWidth;
let imageH = texInfo.mHeight;
this.mElmLeft = left / imageW;
this.mElmRight = right / imageW;
this.mElmBottom = bottom / imageH;
this.mElmTop = top / imageH;
}
注意,setElementPixelPositions()
函数在用相应的实例变量存储结果之前,从像素转换到纹理坐标。
-
添加一个函数来构造适合传递给 WebGL 上下文的纹理坐标规范数组:
-
覆盖
draw()
函数,在实际绘制之前将特定的纹理坐标值加载到 WebGL 上下文中:
getElementUVCoordinateArray() {
return [
this.mElmRight, this.mElmTop, // x,y of top-right
this.mElmLeft, this.mElmTop,
this.mElmRight, this.mElmBottom,
this.mElmLeft, this.mElmBottom
];
}
- 最后,记住导出类和定义的枚举类型:
draw(camera) {
// set the current texture coordinate
// activate the texture
this.mShader.setTextureCoordinate(this.getElementUVCoordinateArray());
super.draw(camera);
}
export default SpriteRenderable;
export {eTexCoordArrayIndex}
使用 shader_resources 促进共享
与SimpleShader
和TextureShader
类似,SpriteShader
是一种可以共享的资源。因此,应该将其添加到发动机的shaderResources
中。
-
在
engine/core/shader_resources.js
文件中,导入SpriteShader
,添加一个变量进行存储,并定义相应的 getter 函数来访问共享的SpriteShader
实例: -
修改
createShaders()
函数来创建SpriteShader
:
import SpriteShader from "../shaders/sprite_shader.js";
let mSpriteShader = null;
function getSpriteShader() { return mSpriteShader; }
function createShaders() {
mConstColorShader = new SimpleShader(kSimpleVS, kSimpleFS);
mTextureShader = new TextureShader(kTextureVS, kTextureFS);
mSpriteShader = new SpriteShader(kTextureVS, kTextureFS);
}
请注意,SpriteShader
实际上覆盖了在texture_vs.glsl
和texture_fs.glsl
文件中定义的现有 GLSL 着色器。从 WebGL 的角度来看,使用纹理进行绘制的功能保持不变。与SpriteShader
的唯一区别是纹理的坐标值现在是可编程的。
-
更新
cleanUp()
函数以正确释放资源: -
确保导出新功能:
function cleanUp() {
mConstColorShader.cleanUp();
mTextureShader.cleanUp();
mSpriteShader.cleanUp();
... identical to previous code ...
}
export {init, cleanUp,
getConstColorShader, getTextureShader, getSpriteShader}
将新功能导出到客户端
将 sprite 元素功能集成到引擎中的最后一步涉及修改引擎访问文件index.js
。编辑index.js
并添加以下导入和导出语句,以授予客户端对SpriteRenderable
和eTexCoordArrayIndex
的访问权限,这是用于访问 WebGL 纹理坐标数组的枚举数据类型。
// renderables
import Renderable from "./renderables/renderable.js";
import TextureRenderable from "./renderables/texture_renderable.js";
import SpriteRenderable from "./renderables/sprite_renderable.js";
import { eTexCoordArrayIndex } from "./renderables/sprite_renderable.js";
... identical to previous code ...
export default {
... identical to previous code ...
// Renderables
Renderable, TextureRenderable, SpriteRenderable,
// constants
eTexCoordArrayIndex,
// functions
init, cleanUp, clearCanvas
}
测试 SpriteRenderable
应该测试 sprite 元素和纹理坐标的两个重要功能:作为对象的 sprite sheet 元素的正确提取、绘制和控制;以及物体上 uv 坐标的改变和控制。为了正确测试添加的功能,您必须修改my_game.js
文件。
-
MyGame
的构造、加载、卸载、绘制与前面的例子类似,这里不再赘述。详情请参考src/my_game
文件夹中的源代码。 -
如下修改
init()
功能。 -
修改了
update()
功能,以支持对英雄对象的控制和对 uv 值的更改。 -
在步骤 A 中设置好摄像机后,注意在步骤 B 中
mPortal
和mCollector
都是基于同一个图像kMinionSprite
创建的,分别调用setElementPixelPositions()
和setElementUVCoordinate()
来指定用于渲染的实际 sprite 元素。 -
步骤 C 创建了另外两个
SpriteRenderable
对象:mFontImage
和mMinion
。sprite 元素 uv 坐标设置是默认设置,其中纹理图像将覆盖整个几何体。 -
类似于步骤 B,步骤 D 基于相同的
kMinionSprite
图像创建英雄角色作为SpriteRenderable
对象。对应于主人公的 sprite 工作表元素用setElementPixelPositions()
调用来标识。请注意,在本例中,创建的五个
SpriteRenderable
对象中有四个基于同一个kMinionSprite
图像。
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(20, 60), // position of the camera
20, // width of camera
[20, 40, 600, 300] // viewport (orgX, orgY, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
// Step B: Create the support objects
this.mPortal = new engine.SpriteRenderable(this.kMinionSprite);
this.mPortal.setColor([1, 0, 0, 0.2]); // tints red
this.mPortal.getXform().setPosition(25, 60);
this.mPortal.getXform().setSize(3, 3);
this.mPortal.setElementPixelPositions(130, 310, 0, 180);
this.mCollector = new engine.SpriteRenderable(this.kMinionSprite);
this.mCollector.setColor([0, 0, 0, 0]); // No tinting
this.mCollector.getXform().setPosition(15, 60);
this.mCollector.getXform().setSize(3, 3);
this.mCollector.setElementUVCoordinate(0.308, 0.483, 0, 0.352);
// Step C: Create the font and minion images using sprite
this.mFontImage = new engine.SpriteRenderable(this.kFontImage);
this.mFontImage.setColor([1, 1, 1, 0]);
this.mFontImage.getXform().setPosition(13, 62);
this.mFontImage.getXform().setSize(4, 4);
this.mMinion = new engine.SpriteRenderable(this.kMinionSprite);
this.mMinion.setColor([1, 1, 1, 0]);
this.mMinion.getXform().setPosition(26, 56);
this.mMinion.getXform().setSize(5, 2.5);
// Step D: Create hero object with texture from lower-left corner
this.mHero = new engine.SpriteRenderable(this.kMinionSprite);
this.mHero.setColor([1, 1, 1, 0]);
this.mHero.getXform().setPosition(20, 60);
this.mHero.getXform().setSize(2, 3);
this.mHero.setElementPixelPositions(0, 120, 0, 180);
}
-
注意键盘控制和 hero 对象的绘制与前面的项目相同。
-
注意对
mFontImage
和mMinion
的setElementUVCoordinate()
调用。这些调用不断减少并重置对应于底部的 V 值,对应于mFontImage
右侧的 U 值,对应于顶部的 V 值,以及对应于mMinion
左侧的 U 值。最终结果是纹理的不断变化和这两个对象上的缩放动画的出现
update() {
// let's only allow the movement of hero,
let deltaX = 0.05;
let xform = this.mHero.getXform();
// Support hero movements
if (engine.input.isKeyPressed(engine.input.keys.Right)) {
xform.incXPosBy(deltaX);
if (xform.getXPos() > 30) { // right-bound of the window
xform.setPosition(12, 60);
}
}
if (engine.input.isKeyPressed(engine.input.keys.Left)) {
xform.incXPosBy(-deltaX);
if (xform.getXPos() < 11) { // left-bound of the window
xform.setXPos(20);
}
}
// continuously change texture tinting
let c = this.mPortal.getColor();
let ca = c[3] + deltaX;
if (ca > 1) {
ca = 0;
}
c[3] = ca;
// New update code for changing the sub-texture regions being shown"
let deltaT = 0.001;
// The font image:
// zoom into the texture by updating texture coordinate
// For font: zoom to the upper left corner by changing bottom right
let texCoord = this.mFontImage.getElementUVCoordinateArray();
// The 8 elements:
// mTexRight, mTexTop, // x,y of top-right
// mTexLeft, mTexTop,
// mTexRight, mTexBottom,
// mTexLeft, mTexBottom
let b = texCoord[engine.eTexCoordArrayIndex.eBottom] + deltaT;
let r = texCoord[engine.eTexCoordArrayIndex.eRight] - deltaT;
if (b > 1.0) {
b = 0;
}
if (r < 0) {
r = 1.0;
}
this.mFontImage.setElementUVCoordinate(
texCoord[engine.eTexCoordArrayIndex.eLeft],
r,
b,
texCoord[engine.eTexCoordArrayIndex.eTop]
);
//
// The minion image:
// For minion: zoom to the bottom right corner by changing top left
texCoord = this.mMinion.getElementUVCoordinateArray();
// The 8 elements:
// mTexRight, mTexTop, // x,y of top-right
// mTexLeft, mTexTop,
// mTexRight, mTexBottom,
// mTexLeft, mTexBottom
let t = texCoord[engine.eTexCoordArrayIndex.eTop] - deltaT;
let l = texCoord[engine.eTexCoordArrayIndex.eLeft] + deltaT;
if (l > 0.5) {
l = 0;
}
if (t < 0.5) {
t = 1.0;
}
this.mMinion.setElementUVCoordinate(
l,
texCoord[engine.eTexCoordArrayIndex.eRight],
texCoord[engine.eTexCoordArrayIndex.eBottom],
t
);
}
精灵动画
在游戏中,您通常希望创建反映角色运动或动作的动画。在上一章中,您学习了如何使用变换运算符移动这些对象的几何图形。然而,正如您在前面的示例中控制英雄角色时所观察到的,如果这些对象上的纹理不以与控制相对应的方式改变,则交互传达的感觉是移动静态图像,而不是使角色运动。需要的是能够在需要时在几何图形上创建动画的幻觉。
在前面的示例中,您从mFontImage
和mMinion
对象中观察到,动画的外观可以通过不断更改纹理映射几何体上的 uv 值来创建。正如本章开始时所讨论的,控制这种类型动画的一种方法是使用动画精灵表。
动画精灵工作表概述
回想一下,动画 sprite sheet 是一个 sprite sheet,它包含动画中对象的图像序列,通常位于一行或多行或多列中。例如,在图 5-11 中,你可以看到一个 2x5 的动画 sprite 表,其中包含两个独立的动画组织成两行。动画描绘了一个对象在顶行中向右收缩其尖峰,并在底行中向左延伸它们。在这个例子中,动画被分成行。动画精灵表也可以定义沿着列的动画。sprite 表的组织和元素像素位置的细节通常由其创建者处理,并且必须明确地传达给游戏开发者以便在游戏中使用。
图 5-11
组织成两行的动画 sprite 工作表,表示同一对象的两个动画序列
图 5-12 显示,为了实现一个对象向右收缩其尖刺的动画效果,如图 5-11 的顶行所示,你按照 1,2,3,4,5 的顺序从左向右映射元素。当这些图像被映射到相同的几何图形,排序,并以适当的速率循环时,它传达了这样一种感觉,即对象确实在重复收回其尖峰的动作。或者,如果顺序颠倒,其中元素以从右到左的顺序映射,则它将创建与向左延伸尖峰的对象相对应的动画。也可以在从左到右的摆动循环中映射序列,然后从右到左返回。在这种情况下,动画将对应于对象不断收缩和伸展其尖状物的运动。
图 5-12
循环的精灵动画序列
精灵动画项目
这个项目演示了如何使用动画精灵表并生成连续的精灵动画。你可以在图 5-13 中看到这个项目运行的例子。项目场景包含前一个场景中的对象以及两个动画对象。这个项目的源代码在chapter5/5.3.sprite_animate_shaders
文件夹中定义。
图 5-13
运行精灵动画着色器项目
该项目的控制措施如下:
-
右箭头键:向右移动英雄;当穿越右边界时,英雄被绕回到左边界
-
左箭头键:右箭头键的反向移动
-
1 号键:从右到左连续显示精灵元素
-
数字键:动画显示精灵元素从左到右和从右到左连续来回移动
-
数字键 3:从左到右连续显示精灵元素
-
数字键 4:增加动画速度
-
数字键 5:降低动画速度
该项目的目标如下:
-
要了解动画精灵工作表
-
体验精灵动画的制作
-
定义实现精灵动画的抽象
您可以在assets
文件夹中找到与上一个项目相同的文件。
SpriteAnimateRenderable 类
精灵动画可以通过策略性地控制SpriteRenderable
的 uv 值来实现,以在期望的时间段显示适当的精灵元素。因此,只需要定义一个类SpriteAnimateRenderable
来支持 sprite 动画。
为了简单和易于理解,以下实现假设与动画相关联的所有 sprite 元素总是沿着同一行组织。例如,在图 5-11 中,长钉的向右收缩和向左伸展运动都是沿一行组织的;两者都不超过一行,也不沿着一列组织。不支持按列组织的动画 sprite 元素。
-
在
src/engine/renderables
文件夹中创建一个新文件,并将其命名为sprite_animate_renderable.js
。 -
为要制作动画的三个不同序列定义枚举数据类型:
// Assumption: first sprite is always the leftmost element.
const eAnimationType = Object.freeze({
eRight: 0, // from left to right, when ended, start from left again
eLeft: 1, // from right animate left-wards,
eSwing: 2 // left to right, then, right to left
});
eAnimationType
枚举定义了三种动画模式:
-
定义从
SpriteRenderable
扩展的SpriteAnimateRenderable
类,并定义构造函数: -
从最左边的元素开始,通过沿着同一行向右迭代来制作动画。当到达最后一个元素时,动画会从最左边的元素开始继续播放。
-
eLeft
是eRight
的反转;它从右边开始,向左移动,到达最左边的元素后,从最右边的元素开始继续。 -
eSwing
是从左到右再从右到左的连续循环。
class SpriteAnimateRenderable extends SpriteRenderable {
constructor(myTexture) {
super(myTexture);
super._setShader(shaderResources.getSpriteShader());
// All coordinates are in texture coordinate (UV between 0 to 1)
// Information on the sprite element
this.mFirstElmLeft = 0.0; // 0.0 is left corner of image
this.mElmTop = 1.0; // image top corner (from SpriteRenderable)
this.mElmWidth = 1.0;
this.mElmHeight = 1.0;
this.mWidthPadding = 0.0;
this.mNumElems = 1; // number of elements in an animation
// per animation settings
this.mUpdateInterval = 1; // how often to advance
this.mAnimationType = eAnimationType.eRight;
this.mCurrentAnimAdvance = -1;
this.mCurrentElm = 0;
this._initAnimation();
}
... implementation to follow ...
SpriteAnimateRenderable
构造函数定义了三组变量:
-
第一组包括
mFirstElmLeft
、mElmTop
等等,定义了每个 sprite 元素的位置和尺寸以及动画中元素的数量。当元素按行和列排序时,此信息可用于精确计算每个 sprite 元素的纹理坐标。注意所有坐标都在纹理坐标空间(0 到 1)。 -
第二组存储关于如何制作动画的信息,左、右或摆动的
mAnimationType
,以及在前进到下一个 sprite 元素之前等待多长时间的mUpdateInterval
。该信息可以在运行时更改,以反转、循环或控制角色的移动速度。 -
第三组
mCurrentAnimAdvance
和mCurrentElm
,描述前进的偏移和当前帧号。这两个变量都是以元素计数为单位的,并不是为游戏程序员设计的,因为它们在内部用于计算下一个要显示的 sprite 元素。
_initAnimation()
函数计算mCurrentAnimAdvance
和mCurrentElm
的值来初始化动画序列。
- 定义
_initAnimation()
函数,根据当前动画类型计算mCurrentAnimAdance
和mCurrentElm
的合适值;
_initAnimation() {
// Currently running animation
this.mCurrentTick = 0;
switch (this.mAnimationType) {
case eAnimationType.eRight:
this.mCurrentElm = 0;
this.mCurrentAnimAdvance = 1; // either 1 or -1
break;
case eAnimationType.eSwing:
this.mCurrentAnimAdvance = -1 * this.mCurrentAnimAdvance;
this.mCurrentElm += 2 * this.mCurrentAnimAdvance;
break;
case eAnimationType.eLeft:
this.mCurrentElm = this.mNumElems - 1;
this.mCurrentAnimAdvance = -1; // either 1 or -1
break;
}
this._setSpriteElement();
}
mCurrentElm
是从最左边偏移的元素数量,mCurrentAnimAdvance
记录每次更新时mCurrentElm
偏移量是应该增加(对于向右动画)还是减少(对于向左动画)。调用_setSpriteElement()
函数来设置与当前识别的 sprite 元素相对应的 uv 值以进行显示。
- 定义
_setSpriteElement()
函数来计算和加载当前识别的 sprite 元素的 uv 值进行渲染:
_setSpriteElement() {
let left = this.mFirstElmLeft +
(this.mCurrentElm * (this.mElmWidth+this.mWidthPadding));
super.setElementUVCoordinate(left, left + this.mElmWidth,
this.mElmTop - this.mElmHeight, this.mElmTop);
}
变量left
是mCurrentElm
的左 u 值,用于计算右 u 值,假设所有动画序列都沿着子画面元素的同一行,并且顶部和底部的 v 值是恒定的,在给定的动画序列中它们不会改变。这些 uv 值被设置为超类SpriteRenderable
进行绘制。
-
定义一个函数来设置动画类型。请注意,当动画类型(左、右或摆动)改变时,动画总是重置为从头开始。
-
定义一个函数来指定精灵动画序列。该函数的输入以像素为单位,通过除以图像的宽度和高度转换为纹理坐标。
setAnimationType(animationType) {
this.mAnimationType = animationType;
this.mCurrentAnimAdvance = -1;
this.mCurrentElm = 0;
this._initAnimation();
}
- 定义直接或通过偏移来更改动画速度的函数:
// Always set the leftmost element to be the first
setSpriteSequence(
topPixel, // offset from top-left
leftPixel, // offset from top-left
elmWidthInPixel,
elmHeightInPixel,
numElements, // number of elements in sequence
wPaddingInPixel // left/right padding
) {
let texInfo = texture.get(this.mTexture);
// entire image width, height
let imageW = texInfo.mWidth;
let imageH = texInfo.mHeight;
this.mNumElems = numElements; // number of elements in animation
this.mFirstElmLeft = leftPixel / imageW;
this.mElmTop = topPixel / imageH;
this.mElmWidth = elmWidthInPixel / imageW;
this.mElmHeight = elmHeightInPixel / imageH;
this.mWidthPadding = wPaddingInPixel / imageW;
this._initAnimation();
}
- 为每次游戏循环更新定义一个推进动画的函数:
setAnimationSpeed(tickInterval) {
this.mUpdateInterval = tickInterval; }
incAnimationSpeed(deltaInterval) {
this.mUpdateInterval += deltaInterval; }
updateAnimation() {
this.mCurrentTick++;
if (this.mCurrentTick >= this.mUpdateInterval) {
this.mCurrentTick = 0;
this.mCurrentElm += this.mCurrentAnimAdvance;
if ((this.mCurrentElm>=0) && (this.mCurrentElm<this.mNumElems)) {
this._setSpriteElement();
} else {
this._initAnimation();
}
}
}
每次调用updateAnimation()
函数时,mCurrentTick
计数器递增,当刻度数达到mUpdateInterval
值时,动画由_initAnimation()
函数重新初始化。需要注意的是,控制动画的时间单位是updateAnimation()
函数被调用的次数,而不是真实世界经过的时间。回想一下,引擎loop.loopOnce()
功能确保系统范围的更新以kMPF
的间隔发生,即使帧速率滞后。游戏引擎架构确保updateAnimation()
函数调用相隔kMPF
毫秒。
- 最后,记住导出定义的类和枚举的动画类型:
export default SpriteAnimateRenderable;
export {eAnimationType}
将新功能导出到客户端
将动画 sprite 元素功能集成到引擎中的最后一步涉及修改引擎访问文件index.js
。编辑index.js
并添加以下导入和导出语句,以授予客户端对SpriteAnimateRenderable
和eAnimationType:
的访问权限
// renderables
import Renderable from "./renderables/renderable.js";
import SpriteRenderable from "./renderables/sprite_renderable.js";
import SpriteAnimateRenderable from
"./renderables/sprite_animate_renderable.js";
import { eTexCoordArrayIndex } from "./renderables/sprite_renderable.js";
import { eAnimationType } from
"./renderables/sprite_animate_renderable.js";
... identical to previous code ...
export default {
... identical to previous code ...
// Renderables
Renderable, TextureRenderable,
SpriteRenderable, SpriteAnimateRenderable,
// constants
eTexCoordArrayIndex, eAnimationType,
// functions
init, cleanUp, clearCanvas
}
测试精灵动画
对象的测试用例必须展示游戏程序员对动画模式(左、右、摇摆)和速度的控制。MyGame
对象被修改来完成这些目的。
-
MyGame
的构建、加载、卸载和绘制与前面的示例类似,不再重复。 -
在
init()
函数中,添加代码来创建和初始化步骤 C 和 D 之间的SpriteAnimateRenderable
对象:
init() {
... identical to previous code ...
// The right minion
this.mRightMinion = new engine.SpriteAnimateRenderable(
this.kMinionSprite);
this.mRightMinion.setColor([1, 1, 1, 0]);
this.mRightMinion.getXform().setPosition(26, 56.5);
this.mRightMinion.getXform().setSize(4, 3.2);
this.mRightMinion.setSpriteSequence(
512, 0, // first element pixel positions: top: 512 left: 0
204, 164, // widthxheight in pixels
5, // number of elements in this sequence
0); // horizontal padding in between
this.mRightMinion.setAnimationType(engine.eAnimationType.eRight);
this.mRightMinion.setAnimationSpeed(50);
// the left minion
this.mLeftMinion = new engine.SpriteAnimateRenderable(
this.kMinionSprite);
this.mLeftMinion.setColor([1, 1, 1, 0]);
this.mLeftMinion.getXform().setPosition(15, 56.5);
this.mLeftMinion.getXform().setSize(4, 3.2);
this.mLeftMinion.setSpriteSequence(
348, 0, // first element pixel positions: top: 164 left: 0
204, 164, // widthxheight in pixels
5, // number of elements in this sequence
0); // horizontal padding in between
this.mLeftMinion.setAnimationType(engine.eAnimationType.eRight);
this.mLeftMinion.setAnimationSpeed(50);
... identical to previous code ...
}
SpriteAnimateRenderable
对象的创建方式类似于SpriteRenderable
对象,使用一个 sprite sheet 作为纹理参数。在这种情况下,有必要调用setSpriteSequence()
函数来识别动画中涉及的元素,包括位置、尺寸和元素总数。
update()
函数必须调用SpriteAnimateRenderable
对象的updateAnimation()
函数来推进精灵动画:
update() {
... identical to previous code ...
// remember to update the minion's animation
this.mRightMinion.updateAnimation();
this.mLeftMinion.updateAnimation();
// Animate left on the sprite sheet
if (engine.input.isKeyClicked(engine.input.keys.One)) {
this.mRightMinion.setAnimationType(engine.eAnimationType.eLeft);
this.mLeftMinion.setAnimationType(engine.eAnimationType.eLeft);
}
// swing animation
if (engine.input.isKeyClicked(engine.input.keys.Two)) {
this.mRightMinion.setAnimationType(engine.eAnimationType.eSwing);
this.mLeftMinion.setAnimationType(engine.eAnimationType.eSwing);
}
// Animate right on the sprite sheet
if (engine.input.isKeyClicked(engine.input.keys.Three)) {
this.mRightMinion.setAnimationType(engine.eAnimationType.eRight);
this.mLeftMinion.setAnimationType(engine.eAnimationType.eRight);
}
// decrease duration of each sprite element to speed up animation
if (engine.input.isKeyClicked(engine.input.keys.Four)) {
this.mRightMinion.incAnimationSpeed(-2);
this.mLeftMinion.incAnimationSpeed(-2);
}
// increase duration of each sprite element to slow down animation
if (engine.input.isKeyClicked(engine.input.keys.Five)) {
this.mRightMinion.incAnimationSpeed(2);
this.mLeftMinion.incAnimationSpeed(2);
}
... identical to previous code ...
}
按键 1、2 和 3 改变动画类型,按键 4 和 5 改变动画速度。注意,动画速度的极限是游戏循环的更新速率。
文本的字体和绘图
许多游戏用于各种任务的一个有价值的工具是文本输出。绘制文本消息是与用户以及开发人员沟通的有效方式。例如,在开发过程中,可以使用文本消息来交流游戏的故事、玩家的分数或调试信息。遗憾的是,WebGL 不支持文本的绘制。本节简单介绍位图字体,并引入FontRenderable
对象来支持文本的绘制。
位图字体
必须定义字体,以便可以提取单个字符来绘制文本消息。顾名思义,位图字体是一个简单的映射,描述了必须打开哪个位(或像素)来表示字体中的字符。将位图字体的所有字符组合成单个图像并定义伴随的解码描述文档为绘制文本输出提供了直接的解决方案。例如,图 5-14 显示了一个位图字体精灵,其中所有已定义的字符都紧密地组织在同一个图像中。图 5-15 是附带的 XML 格式解码描述的片段。
图 5-15
图 5-14 中所示的带有位图字体图像解码信息的 XML 文件片段
图 5-14
位图字体精灵图像示例
注意,如图 5-15 所示的解码信息唯一定义了图像中每个字符的 uv 坐标位置,如图 5-14 所示。这样,SpriteRenderable
对象可以直接显示位图字体精灵图像中的单个字符。
Note
有许多位图字体文件格式。本书中使用的格式是 XML 格式的 AngleCode BMFont 兼容字体。BMFont 是一款开源软件,可以将 TrueType、OpenType 等矢量字体转换成位图字体。更多信息见 www.angelcode.com/products/bmfont/
。
字体支持项目
这个项目演示了如何使用SpriteRenderable
对象从位图字体中绘制文本。你可以在图 5-16 中看到这个项目运行的例子。这个项目的源代码在chapter5/5.4.font_support
文件夹中定义。
图 5-16
运行字体支持项目
该项目的控制措施如下:
-
数字键 0、1、2、3 :分别选择 Consolas、16、24、32 或 72 种字体进行尺寸修改。
-
按住 X/Y 键的同时按向上/向下键:增加或减少(箭头键)所选字体的宽度(X 键)或高度(Y 键)。
-
左右箭头键:向左或向右移动英雄。如果英雄出界,他就结束比赛。
该项目的目标如下:
-
要了解位图字体
-
获得在游戏中绘制文本字符串的基本理解
-
在游戏引擎中实现文本绘制支持
您可以在assets
文件夹中找到以下外部资源文件:consolas-72.png
和minion_sprite.png
。在assets/fonts
文件夹中是位图字体 sprite 图像文件和包含解码信息的相关 XML 文件:consolas-16.fnt
、consolas-16.png
、consolas-24.fnt
、consolas-24.png
、consolas-32.fnt
、consolas-32.png
、consolas-72.fnt
、consolas-72.png
、segment7-96.fnt
、segment7-96.png
、system-default-font.fnt
和system-default-font.png
。
请注意,.fnt
和.png
文件是成对的。前者包含后者的解码信息。这些文件对必须包含在同一文件夹中,以便引擎正确加载字体。system-default-font
是游戏引擎的默认字体,假设该字体一直存在于asset/fonts
文件夹中。
Note
从.fnt
文件中解析、解码和提取角色信息的动作独立于游戏引擎的基本操作。因此,不介绍这些操作的细节。有兴趣的话可以去查阅一下源代码。
在引擎中加载和存储字体
加载字体文件是特殊的,因为字体是成对定义的:包含解码信息的.fnt
文件和相应的.png
sprite 图像文件。然而,由于.fnt
文件是一个 XML 文件,而.png
文件是一个简单的纹理图像,引擎已经支持这两种类型文件的加载。引擎中加载和存储字体的细节由一个新的引擎组件font
封装。
-
在
src/engine/resources
文件夹中创建一个新文件,并将其命名为font.js
。 -
从用于加载
.fnt
文件的xml
模块和用于.png
sprite 图像文件的texture
模块中导入资源管理功能,并为这些文件扩展名定义本地常量: -
定义一个类,用于存储与角色关联的 uv 坐标位置和大小。该信息可以根据
.fnt
文件的内容进行计算。
import * as xml from "./xml.js";
import * as texture from "./texture.js";
let kDescExt = ".fnt"; // extension for the bitmap font description
let kImageExt = ".png"; // extension for the bitmap font image
- 定义两个函数,根据不带文件扩展名的路径返回正确的扩展名。注意
fontName
是字体文件的路径,但没有任何文件扩展名。例如,assets/fonts/system-default-font
是字符串,这两个函数标识两个关联的.fnt
和.png
文件。
class CharacterInfo {
constructor() {
// in texture coordinate (0 to 1) maps to the entire image
this.mTexCoordLeft = 0;
this.mTexCoordRight = 1;
this.mTexCoordBottom = 0;
this.mTexCoordTop = 0;
// nominal char size, 1 is "standard width/height" of a char
this.mCharWidth = 1;
this.mCharHeight = 1;
this.mCharWidthOffset = 0;
this.mCharHeightOffset = 0;
// reference of char width/height ratio
this.mCharAspectRatio = 1;
}
}
- 定义
load()
和unload()
功能。注意,实际上在每个文件中调用了两个文件操作:一个是针对.fnt
文件,另一个是针对.png
文件。
function descName(fontName) { return fontName+kDescExt;}
function imageName(fontName) { return fontName+kImageExt;}
- 定义一个函数来查询给定字体的加载状态:
function load(fontName) {
xml.load(descName(fontName));
texture.load(imageName(fontName));
}
function unload(fontName) {
xml.unload(descName(fontName));
texture.unload(imageName(fontName));
}
- 定义一个函数,根据
.fnt
文件中的信息计算CharacterInfo
:
function has(fontName) {
return texture.has(imageName(fontName)) &&
xml.has(descName(fontName));
}
function getCharInfo(fontName, aChar) {
... details omitted for lack of relevancy
returnInfo = new CharacterInfo();
// computes and fills in the contents of CharacterInfo
... details omitted for lack of relevancy
return returnInfo;
};
省略了给定角色的解码和提取信息的细节,因为它们与游戏引擎实现的其余部分无关。
Note
关于.fnt
格式的详细信息,请参考 www.angelcode.com/products/bmfont/doc/file_format.html
。
- 最后,记住从这个模块导出函数:
export {has, load, unload,
imageName, descName,
CharacterInfo,
getCharInfo
}
向引擎添加默认字体
为了游戏程序员的方便,游戏引擎应该提供默认的系统字体。为了实现这一点,应该定义一个引擎实用程序来加载和初始化与游戏开发者共享的默认资源。回想一下,src/engine/core
文件夹中的shader_resources
模块被定义为支持引擎范围的着色器共享。可以复制这种模式,以便与客户机共享默认资源。可以在src/engine/resources
文件夹中定义一个default_resources
模块来完成这种共享。
-
在
src/engine/resources
文件夹中创建一个文件,命名为default_resources.js
,从font
和resource_map
模块中导入功能,为默认系统字体的路径定义一个常量字符串及其 getter 函数: -
定义一个
init()
函数,在 JavaScriptPromise
中发出默认的系统字体加载请求,并将Promise
添加到resource_map
中未完成的加载请求数组中。回想一下,loop
模块中的loop.start()
功能在开始游戏循环之前等待所有resource_map
加载承诺的履行。因此,与所有其他异步加载的资源一样,当游戏循环开始时,默认的系统字体将被正确加载。
import * as font from "./font.js";
import * as map from "../core/resource_map.js";
// Default font
let kDefaultFont = "assets/fonts/system_default_font";
var getDefaultFont = function() { return kDefaultFont; }
- 定义
cleanUp()
函数释放所有分配的资源,在这种情况下,卸载字体:
function init() {
let loadPromise = new Promise(
async function (resolve) {
await Promise.all([
font.load(kDefaultFont)
]);
resolve();
}).then(
function resolve() { /* nothing to do for font */ }
);
map.pushPromise(loadPromise);
}
- 最后,记住导出所有已定义的功能:
// unload all resources
function cleanUp() {
font.unload(kDefaultFont);
}
export {
init, cleanUp,
// default system font name: this is guaranteed to be loaded
getDefaultFontName
}
定义一个 FontRenderable 对象来绘制文本
定义的font
模块能够加载字体文件并提取每个字符的 uv 坐标和大小信息。有了这个功能,通过识别字符串中的每个字符,检索相应的纹理映射信息,并使用SpriteRenderable
对象渲染字符,就可以完成文本字符串的绘制。将定义FontRenderable
对象来完成这个任务。
-
在
src/engine/renderables
文件夹中创建一个新文件,并将其命名为font_renderable.js
。 -
定义
FontRenderable
类及其构造函数,接受一个字符串作为其参数: -
定义
draw()
函数,使用mOneChar
变量解析并绘制字符串中的每个字符: -
aString
变量是要绘制的消息。 -
请注意,
FontRenderable
对象不定制SpriteRenderable
对象的行为。相反,它依靠一个SpriteRenderable
对象来绘制字符串中的每个字符。因此,FontRenderable
不是的子类,而是包含了SpriteRenderable
对象的一个实例,即mOneChar
变量。
class FontRenderable {
constructor(aString) {
this.mFontName = defaultResources.getDefaultFontName();
this.mOneChar = new SpriteRenderable(
font.imageName(this.mFontName));
this.mXform = new Transform(); // to move this object around
this.mText = aString;
}
... implementation to follow ...
draw(camera) {
// we will draw the text string by calling mOneChar for each of the
// chars in the mText string.
let widthOfOneChar = this.mXform.getWidth() / this.mText.length;
let heightOfOneChar = this.mXform.getHeight();
let yPos = this.mXform.getYPos();
// center position of the first char
let xPos = this.mXform.getXPos() -
(widthOfOneChar / 2) + (widthOfOneChar * 0.5);
let charIndex, aChar, charInfo, xSize, ySize, xOffset, yOffset;
for (charIndex = 0; charIndex < this.mText.length; charIndex++) {
aChar = this.mText.charCodeAt(charIndex);
charInfo = font.getCharInfo(this.mFontName, aChar);
// set the texture coordinate
this.mOneChar.setElementUVCoordinate(
charInfo.mTexCoordLeft, charInfo.mTexCoordRight,
charInfo.mTexCoordBottom, charInfo.mTexCoordTop);
// now the size of the char
xSize = widthOfOneChar * charInfo.mCharWidth;
ySize = heightOfOneChar * charInfo.mCharHeight;
this.mOneChar.getXform().setSize(xSize, ySize);
// how much to offset from the center
xOffset = widthOfOneChar * charInfo.mCharWidthOffset * 0.5;
yOffset = heightOfOneChar * charInfo.mCharHeightOffset * 0.5;
this.mOneChar.getXform().setPosition(xPos-xOffset, yPos-yOffset);
this.mOneChar.draw(camera);
xPos += widthOfOneChar;
}
}
每个字符的尺寸由widthOfOneChar
和heightOfOneChar
定义,其中宽度就是总的FontRenderable
宽度除以字符串中的字符数。然后for
循环执行以下操作:
-
实现转换的 getters 和 setters、要绘制的文本消息、用于绘制的字体和颜色:
-
提取字符串中的每个字符
-
调用
getCharInfo()
函数来接收charInfo
中角色的 uv 值和大小信息 -
使用来自
charInfo
的 uv 值来识别mOneChar
的精灵元素位置(通过调用并将信息传递给mOneChar.setElementUVCoordinate()
函数) -
使用来自
charInfo
的尺寸信息计算角色的实际尺寸(xSize
和ySize
)和位置偏移(xOffset
和yOffset
),并使用适当的设置绘制角色mOneChar
-
定义
setTextHeight()
函数来定义要输出的消息的高度:
getXform() { return this.mXform; }
getText() { return this.mText; }
setText(t) {
this.mText = t;
this.setTextHeight(this.getXform().getHeight());
}
getFontName() { return this.mFontName; }
setFontName(f) {
this.mFontName = f;
this.mOneChar.setTexture(font.imageName(this.mFontName));
}
setColor(c) { this.mOneChar.setColor(c); }
getColor() { return this.mOneChar.getColor(); }
setTextHeight(h) {
let charInfo = font.getCharInfo(this.mFontName, "A".charCodeAt(0));
let w = h * charInfo.mCharAspectRatio;
this.getXform().setSize(w * this.mText.length, h);
}
请注意,要绘制的整个消息的宽度是根据消息字符串长度自动计算的,并保持字符宽度与高度的纵横比。
- 最后,记住导出定义的类:
export default FrontRenderable;
Note
FontRenderable
不支持整个消息的旋转。文本消息总是从左向右水*绘制。
初始化、清理和导出字体功能
与所有引擎功能一样,更新引擎访问文件index.js
以授予游戏开发者访问权限非常重要。在这种情况下,初始化和清理与默认系统字体相关联的资源也很重要。
-
编辑
index.js
以从font
和default_resources
模块和FontRenderable
类导入功能: -
在引擎
init()
和cleanUp()
函数中添加默认资源初始化和清理:
// resources
import * as audio from "./resources/audio.js";
import * as text from "./resources/text.js";
import * as xml from "./resources/xml.js";
import * as texture from "./resources/texture.js";
import * as font from "./resources/font.js";
import * as defaultResources from "./resources/default_resources.js";
... identical to previous code ...
// renderables
import Renderable from "./renderables/renderable.js";
import SpriteRenderable from "./renderables/sprite_renderable.js";
import SpriteAnimateRenderable from
"./renderables/sprite_animate_renderable.js";
import FontRenderable from "./renderables/font_renderable.js";
... identical to previous code ...
- 记住导出新定义的功能:
function init(htmlCanvasID) {
glSys.init(htmlCanvasID);
vertexBuffer.init();
input.init();
audio.init();
shaderResources.init();
defaultResources.init();
}
function cleanUp() {
loop.cleanUp();
shaderResources.cleanUp();
defaultResources.cleanUp();
audio.cleanUp();
input.cleanUp();
vertexBuffer.cleanUp();
glSys.cleanUp();
}
export default {
// resource support
audio, text, xml, texture, font, defaultResources,
... identical to previous code ...
// Renderables
Renderable, TextureRenderable,
SpriteRenderable, SpriteAnimateRenderable, FontRenderable,
... identical to previous code ...
}
测试字体
您现在可以修改MyGame
场景,用assets
文件夹中的各种字体打印消息:
-
在
my_game.js
文件中,修改构造函数来定义打印消息的相应变量,并修改draw()
函数来相应地绘制所有对象。有关代码的详细信息,请参考src/my_game/my_game.js
文件。 -
修改
load()
功能以加载纹理和字体。再次注意,字体路径,例如assets/fonts/consolas-16
,不包括文件扩展名。回想一下,这个路径后面会追加.fnt
和.png
,其中会加载两个独立的文件来支持字体的绘制。 -
修改
unload()
功能,卸载纹理和字体:
load() {
// Step A: loads the textures
engine.texture.load(this.kFontImage);
engine.texture.load(this.kMinionSprite);
// Step B: loads all the fonts
engine.font.load(this.kFontCon16);
engine.font.load(this.kFontCon24);
engine.font.load(this.kFontCon32);
engine.font.load(this.kFontCon72);
engine.font.load(this.kFontSeg96);
}
- 定义一个私有的
_initText()
函数来设置FontRenderable
对象的颜色、位置和高度。修改init()
功能,设置正确的 WC 系统并初始化字体。注意对setFont()
函数的调用,以改变每条消息的字体类型。
unload() {
engine.texture.unload(this.kFontImage);
engine.texture.unload(this.kMinionSprite);
// unload the fonts
engine.font.unload(this.kFontCon16);
engine.font.unload(this.kFontCon24);
engine.font.unload(this.kFontCon32);
engine.font.unload(this.kFontCon72);
engine.font.unload(this.kFontSeg96);
}
- 用以下内容修改
update()
功能:
_initText(font, posX, posY, color, textH) {
font.setColor(color);
font.getXform().setPosition(posX, posY);
font.setTextHeight(textH);
}
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(50, 33), // position of the camera
100, // width of camera
[0, 0, 600, 400] // viewport (orgX, orgY, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
// Step B: Create the font and minion images using sprite
this.mFontImage = new engine.SpriteRenderable(this.kFontImage);
this.mFontImage.setColor([1, 1, 1, 0]);
this.mFontImage.getXform().setPosition(15, 50);
this.mFontImage.getXform().setSize(20, 20);
// The right minion
this.mMinion = new engine.SpriteAnimateRenderable(
this.kMinionSprite);
this.mMinion.setColor([1, 1, 1, 0]);
this.mMinion.getXform().setPosition(15, 25);
this.mMinion.getXform().setSize(24, 19.2);
this.mMinion.setSpriteSequence(512, 0, // first element: top, left
204, 164, // widthxheight in pixels
5, // number of elements in this sequence
0); // horizontal padding in between
this.mMinion.setAnimationType(engine.eAnimationType.eSwing);
this.mMinion.setAnimationSpeed(15);
// show each element for mAnimSpeed updates
// Step D: Create hero object with texture from lower-left corner
this.mHero = new engine.SpriteRenderable(this.kMinionSprite);
this.mHero.setColor([1, 1, 1, 0]);
this.mHero.getXform().setPosition(35, 50);
this.mHero.getXform().setSize(12, 18);
this.mHero.setElementPixelPositions(0, 120, 0, 180);
// Create the fonts
this.mTextSysFont = new engine.FontRenderable("System Font: in Red");
this._initText(this.mTextSysFont, 50, 60, [1, 0, 0, 1], 3);
this.mTextCon16 = new engine.FontRenderable("Consolas 16: in black");
this.mTextCon16.setFontName(this.kFontCon16);
this._initText(this.mTextCon16, 50, 55, [0, 0, 0, 1], 2);
this.mTextCon24 = new engine.FontRenderable("Consolas 24: in black");
this.mTextCon24.setFontName(this.kFontCon24);
this._initText(this.mTextCon24, 50, 50, [0, 0, 0, 1], 3);
this.mTextCon32 = new engine.FontRenderable("Consolas 32: in white");
this.mTextCon32.setFontName(this.kFontCon32);
this._initText(this.mTextCon32, 40, 40, [1, 1, 1, 1], 4);
this.mTextCon72 = new engine.FontRenderable("Consolas 72: in blue");
this.mTextCon72.setFontName(this.kFontCon72);
this._initText(this.mTextCon72, 30, 30, [0, 0, 1, 1], 6);
this.mTextSeg96 = new engine.FontRenderable("Segment7-92");
this.mTextSeg96.setFontName(this.kFontSeg96);
this._initText(this.mTextSeg96, 30, 15, [1, 1, 0, 1], 7);
this.mTextToWork = this.mTextCon16;
}
update() {
... identical to previous code ...
// choose which text to work on
if (engine.input.isKeyClicked(engine.input.keys.Zero)) {
this.mTextToWork = this.mTextCon16;
}
if (engine.input.isKeyClicked(engine.input.keys.One)) {
this.mTextToWork = this.mTextCon24;
}
if (engine.input.isKeyClicked(engine.input.keys.Three)) {
this.mTextToWork = this.mTextCon32;
}
if (engine.input.isKeyClicked(engine.input.keys.Four)) {
this.mTextToWork = this.mTextCon72;
}
let deltaF = 0.005;
if (engine.input.isKeyPressed(engine.input.keys.Up)) {
if (engine.input.isKeyPressed(engine.input.keys.X)) {
this.mTextToWork.getXform().incWidthBy(deltaF);
}
if (engine.input.isKeyPressed(engine.input.keys.Y)) {
this.mTextToWork.getXform().incHeightBy(deltaF);
}
this.mTextSysFont.setText(
this.mTextToWork.getXform().getWidth().toFixed(2) + "x" +
this.mTextToWork.getXform().getHeight().toFixed(2));
}
if (engine.input.isKeyPressed(engine.input.keys.Down)) {
if (engine.input.isKeyPressed(engine.input.keys.X)) {
this.mTextToWork.getXform().incWidthBy(-deltaF);
}
if (engine.input.isKeyPressed(engine.input.keys.Y)) {
this.mTextToWork.getXform().incHeightBy(-deltaF);
}
this.mTextSysFont.setText(
this.mTextToWork.getXform().getWidth().toFixed(2) + "x" +
this.mTextToWork.getXform().getHeight().toFixed(2));
}
}
列出的代码显示您可以在运行时执行以下操作:
-
根据键盘 0 到 4 的输入选择使用哪个
FontRenderable
对象。 -
当同时按下左/右箭头键和 X/Y 键时,控制所选
FontRenderable
对象的宽度和高度。
现在,您可以与字体支持项目交互,以修改每个显示的字体消息的大小,并向左和向右移动主角。
摘要
在这一章中,你学习了如何在单位方格上粘贴或纹理映射图像,以更好地表现游戏中的物体。您还学习了如何识别图像的选定子区域,以及如何基于标准化范围的纹理坐标系将纹理映射到单位正方形。本章然后解释了精灵表如何减少加载纹理图像所需的时间,同时促进动画的创建。这一知识然后被推广并应用于位图字体的绘制。
纹理映射和子画面渲染的实现利用了游戏引擎架构的一个重要方面:SimpleShader
/ Renderable
对象对,其中 JavaScript SimpleShader
对象被定义为与相应的 GLSL 着色器和Renderable
对象接口,以促进多个对象实例的创建和交互。例如,你创建了TextureShader
与TextureVS
和TextureFS
GLSL 着色器接口,并创建了TextureRenderable
供游戏程序员使用。对SpriteShader
和SpriteRenderable
重复相同的模式。与SpriteAnimateRenderable
配对的SpriteShader
对象的经验表明,在适当的时候,同一个着色器对象可以在游戏引擎中支持多种可渲染对象类型。这个SimpleShader
/ Renderable
对实现模式会在第八章再次出现,当你学习创建 3D 光照效果的时候。
在这一章的开始,你的游戏引擎支持玩家用键盘操作对象,并以不同的大小和方向绘制这些对象。有了本章的功能,您现在可以用有趣的图像来表示这些对象,并在需要时创建这些对象的动画。在下一章中,您将了解如何定义和支持这些对象的行为,包括伪自主行为,如追逐和碰撞检测。
游戏设计注意事项
在第四章中,你学习了反应灵敏的游戏反馈对于让玩家感受到与游戏世界的联系是多么重要,这种联系感在游戏设计中被称为存在感。当你阅读本书的后续章节时,你会注意到大多数游戏设计最终都是以这样或那样的方式来增强临场感,你会发现视觉设计是临场感最重要的贡献者之一。例如,想象一个游戏,玩家控制的一个物体(也称为英雄物体)必须在 2D *台风格的游戏世界中机动;玩家的目标可能是使用鼠标和键盘在游戏中呈现的各个表面之间跳跃英雄,而不会穿过这些表面之间存在的间隙。环境中英雄和其他物体的视觉表现决定了玩家如何认同游戏设置,进而决定了游戏如何有效地创造临场感:英雄是被表现为一个有生命的生物,还是只是一个像正方形或圆形这样的抽象形状?这些表面是被描绘成建筑物的屋顶、外星球上漂浮的岩石,还是仅仅被描绘成抽象的矩形?在选择视觉表现或游戏设置时,没有正确或错误的答案,但重要的是为所有游戏元素设计一种视觉风格,感觉统一并集成到您选择的任何游戏设置中(例如,如果您的游戏设置是热带雨林,抽象的矩形*台可能会对存在产生负面影响)。
纹理着色器项目展示了.png
透明图像如何比不支持透明的.jpg
等格式更有效地将游戏元素集成到游戏环境中。如果你向右移动英雄(这里简单地表示为一个矩形),屏幕上没有任何变化,但是如果你向左移动英雄,你最终会触发一个状态变化,改变显示的视觉元素,就像你在第四章的场景对象项目中所做的那样。请注意,当机器人精灵是在灰色背景上透明的.png
文件时,与它们是在蓝色背景上不透明的.jpg
图像时相比,它们更有效地融入了游戏场景。
精灵着色器项目引入了一个与游戏设置中的其他元素更加匹配的英雄:你已经用一个在风格上与屏幕上的飞行机器人匹配的人形人物替换了纹理着色器项目中的矩形,并且矩形英雄图像中未被人形人物占据的区域是透明的。如果您要将 Sprite Shaders 项目中的英雄与纹理着色器项目中的屏幕改变动作结合起来,想象一下当英雄向屏幕右侧的机器人移动时,机器人可能会在英雄过于靠*时变红。在这一点上,编码的事件仍然很简单,但是你可以看到视觉设计和一些简单的触发动作已经开始传达游戏设置和增强临场感。
请注意,作为游戏设计者,我们经常迷恋于非常详细和精细的视觉设计,我们开始相信制作最好的游戏需要更高的保真度和更精细的视觉元素;这种对更强大图形的驱动是许多 AAA 游戏参与竞争的熟悉竞赛。虽然游戏体验和存在感在与优秀的艺术指导搭配时确实可以得到相当大的增强,但优秀并不总是需要精心和复杂的。伟大的艺术指导依赖于开发一种统一的视觉语言,其中所有元素相互协调,并有助于推动游戏向前发展,这种协调可以通过任何东西来实现,从 2D *面中的简单形状和颜色到超真实 3D 环境以及两者之间的任何组合。
向游戏的视觉元素添加动画运动可以进一步增强游戏的真实感,因为动画为游戏带来了电影般的动感,从而进一步将玩家与游戏世界联系起来。在我们的世界中,我们通常以互联系统的形式体验运动;例如,当你穿过房间时,你不只是滑行而不移动身体,而是以不同的方式一起移动身体的不同部分。通过向屏幕上的对象添加有针对性的动画,使这些对象以您可能期望复杂系统移动或动作的方式运行,您可以以更身临其境和令人信服的方式将玩家与游戏世界中正在发生的事情联系起来。精灵动画项目演示了动画如何通过让你清晰地表达飞行机器人的尖刺,控制方向和速度来增加存在感。再想象一下把精灵动画项目和本章前面的项目结合起来;当英雄靠*机器人时,它可能会先变红,最终触发机器人的动画,并使其靠*或远离玩家。动画通常在游戏设计过程中出现得相当晚,因为通常有必要首先很好地定义游戏机制和其他系统,以避免环境和关卡设计更新时可能需要的耗时的更改。设计师通常在开发的早期阶段使用简单的占位符资产,只有当游戏的所有其他元素都已最终确定时,才添加精致和动画的最终资产,以最大限度地减少返工的需要。
就像视觉设计一样,动画方法不需要复杂就能有效。虽然动画需要有意识的和统一的,并且应该感觉流畅,没有口吃,除非它是有意设计的,但在如何在屏幕上表现运动方面可以采用很大程度的艺术许可。
字体支持项目向你介绍游戏字体。虽然字体很少对游戏有直接影响,但它们可以对存在感产生巨大影响。字体是视觉交流的一种形式,字体的风格通常和它所传达的文字一样重要,可以支持或削弱游戏的设置和视觉风格。特别注意这个项目中显示的字体,注意黄色字体如何传达一种与科幻小说启发的英雄和机器人的视觉风格相匹配的数字感觉,而 Consolas 字体家族及其圆形字体与这个游戏设置有点格格不入(尽管游戏设置可能仍然稀疏)。作为一个更极端的例子,想象一下在一个发生在宇宙飞船上的未来主义游戏中,一种流畅的书法字体(通常用于高幻想游戏中的类型)会出现怎样的不连贯。
有多少人和想法,游戏就有多少视觉风格的可能性,伟大的游戏可以采用极其简单的图形。请记住,优秀的游戏设计是九个要素的组合(如果你需要刷新你的记忆,请回到引言),作为一个游戏设计师,要记住的最重要的事情是保持专注于这些要素中的每一个如何与其他要素协调并提升其他要素,以创造出大于其部分总和的东西。
六、定义行为和检测碰撞
完成本章后,您将能够
-
实现自主行为,如锁定目标追逐和逐步转向
-
精确碰撞纹理物体
-
了解像素精确碰撞的效率问题
-
有效且高效地使用像素精确碰撞编程
介绍
至此,您的游戏引擎能够在方便的坐标系中实现游戏,并呈现和动画化视觉上吸引人的对象。然而,缺乏对对象行为的抽象支持。你可以在之前所有项目中的MyGame
对象的init()
和update()
函数中看到这一缺点的直接结果:init()
函数经常挤满了*凡的每游戏对象设置,而update()
函数经常挤满了控制对象的条件语句,例如检查移动英雄的按键。
一个设计良好的系统应该用适当的面向对象的抽象或类来隐藏单个对象的初始化和控制。应该引入一个抽象的GameObject
类来封装和隐藏其初始化和行为的细节。这种方法有两个主要优点。首先,游戏级别的init()
和update()
功能可以专注于管理单个游戏对象和这些对象的交互,而不与特定于不同类型对象的细节聚集在一起。第二,正如你已经体验过的Renderable
和SimpleShader
类的层次结构一样,适当的面向对象抽象创建了一个标准化的接口,并促进了代码的共享和重用。
当你从单纯的绘制对象(换句话说,Renderable
)过渡到对对象的行为进行编程(换句话说,GameObject
)时,你会立即注意到,为了让游戏变得有趣,对象需要进行交互。物体有趣的行为,比如面对或躲避敌人,往往需要知道游戏中其他物体的相对位置。一般来说,解析 2D 世界中所有物体的相对位置并不简单。幸运的是,典型的视频游戏只需要知道那些彼此非常接*或者将要碰撞或者已经碰撞的物体。
检测碰撞的一种有效但有些粗糙的*似方法是计算对象的边界,并基于碰撞边界框来*似对象碰撞。在最简单的情况下,边界框是边缘与 x/y 轴对齐的矩形框。这些被称为轴对齐边界框或 AABBs。由于轴对齐,检测两个 AABBs 何时重叠或何时将要发生碰撞在计算上是有效的。
许多 2D 游戏引擎还可以通过比较两个对象的像素位置并检测至少一个不透明像素重叠的情况,来检测两个纹理对象之间的实际碰撞。这种计算密集型过程称为逐像素精确碰撞检测、逐像素精确碰撞或逐像素碰撞。
本章首先介绍了GameObject
类,它提供了一个抽象游戏对象行为的*台。然后将GameObject
类一般化,引入常见的行为属性,包括速度、运动方向和锁定目标的追逐。本章的其余部分集中在派生一个有效的每像素精确碰撞实现,支持纹理和动画精灵对象。
游戏对象
如上所述,应该引入封装典型游戏对象的内在行为的抽象,以最小化游戏级别的init()
和update()
函数中的代码集群,并促进重用。本节介绍了简单的GameObject
类,以说明干净整洁的init()
和update()
函数如何清晰地反映游戏中的逻辑,并演示抽象对象行为的基本*台如何促进设计和代码重用。
游戏对象项目
这个项目将简单的GameObject
类定义为构建抽象的第一步,用游戏中的行为来表示实际的对象。你可以在图 6-1 中看到这个项目运行的例子。请注意,许多奴才从右向左冲锋,并在到达左边界时绕回。这个项目引导您创建基础设施来支持许多奴才,同时保持MyGame
级别的逻辑简单。这个项目的源代码在chapter6/6.1.game_objects
文件夹中定义。
图 6-1
运行游戏对象项目
该项目的控制措施如下:
- WASD 键:上下左右移动英雄
该项目的目标如下:
-
开始定义
GameObject
类来封装游戏中的对象行为 -
演示如何创建
GameObject
类的子类以保持MyGame
级update()
函数的简单性 -
引入
GameObjectSet
类,展示对一组具有相同接口的同质对象的支持
您可以在assets
文件夹中找到以下外部资源文件:minion_sprite.png
;你还会找到包含默认系统字体的fonts
文件夹。注意,如图 6-2 所示,minion_sprite.png
图像文件已经从之前的项目更新为包含两个额外的 sprite 元素:DyePack
和Brain
minion。
图 6-2
minion_sprite.png
图像的新 sprite 元素
定义游戏对象类
目标是定义一个逻辑抽象来封装游戏中典型对象的所有相关行为特征,包括控制位置、绘图等的能力。就像前一章中的Scene
对象一样,主要结果是提供了一个定义良好的接口来管理子类实现的功能。更复杂的行为将在下一节介绍。这个例子仅仅展示了定义了最少行为的GameObject
类的潜力。
-
添加一个新的文件夹
src/engine/game_objects
,用于存储与GameObject
相关的文件。 -
在该文件夹中创建一个新文件,将其命名为
game_object.js
,并添加以下代码:
class GameObject {
constructor(renderable) {
this.mRenderComponent = renderable;
}
getXform() { return this.mRenderComponent.getXform(); }
getRenderable() { return this.mRenderComponent; }
update() { }
draw(aCamera) {
this.mRenderComponent.draw(aCamera);
}
}
export default GameObject;
定义了Renderable
和Transform
对象的评估者后,所有的GameObject
实例都可以被绘制出来,并具有定义的位置和大小。请注意,update()
函数是为子类设计的,可以覆盖特定于对象的行为,因此,它是空的。
管理集合中的游戏对象
因为大多数游戏由许多交互对象组成,所以定义一个实用程序类来支持使用一组GameObject
实例是很有用的:
-
在
src/engine/game_objects
文件夹中创建一个新文件,并将其命名为game_object_set.js
。定义GameObjectSet
类和构造函数来初始化保存GameObject
实例的数组。 -
定义用于管理集合成员资格的函数:
class GameObjectSet {
constructor() {
this.mSet = [];
}
... implementation to follow ...
export default GameObjectSet;
- 定义函数来更新和绘制集合中的每个
GameObject
实例:
size() { return this.mSet.length; }
getObjectAt(index) { return this.mSet[index]; }
addToSet(obj) { this.mSet.push(obj); }
removeFromSet(obj) {
let index = this.mSet.indexOf(obj);
if (index > -1)
this.mSet.splice(index, 1);
}
update() {
let i;
for (i = 0; i < this.mSet.length; i++) {
this.mSet[i].update();
}
}
draw(aCamera) {
let i;
for (i = 0; i < this.mSet.length; i++) {
this.mSet[i].draw(aCamera);
}
}
将类导出到客户端
将任何新功能集成到引擎中的最后一步涉及修改引擎访问文件index.js
。编辑index.js
并添加以下导入和导出语句,以授予客户端对GameObject
和GameObjectSet
类的访问权限:
... identical to previous code ...
// game objects
import GameObject from "./game_objects/game_object.js";
import GameObjectSet from "./game_objects/game_object_set.js";
... identical to previous code ...
export default {
... identical to previous code ...
// Game Objects
GameObject, GameObjectSet,
... identical to previous code ...
}
Note
对于每个新定义的功能,必须重复通过引擎访问文件index.js
导入/导出类的过程。从今以后,将只提供一个提醒,简单的代码更改将不再显示。
测试游戏对象和游戏对象集
这个项目的目标是确保新的GameObject
类的正常运行,演示单个对象类型的行为定制,并观察一个更清晰的MyGame
实现,清楚地反映游戏中的逻辑。为了实现这些目标,定义了三种对象类型:DyePack
、Hero
和Minion
。在开始研究这些对象的详细实现之前,遵循良好的源代码组织实践,创建一个新文件夹src/my_game/objects
来存储新的对象类型。
迪埃帕克游戏对象
DyePack
类从GameObject
类派生而来,演示了最基本的GameObject
的例子:一个没有任何行为的对象,它只是被绘制到屏幕上。
在src/my_game/objects
文件夹中创建一个新文件,并将其命名为dye_pack.js
。从引擎访问文件index.js
导入,获得游戏引擎的所有访问功能。将DyePack
定义为GameObject
的子类,并如下实现构造函数:
import engine from "../../engine/index.js";
class DyePack extends engine.GameObject {
constructor(spriteTexture) {
super(null);
this.kRefWidth = 80;
this.kRefHeight = 130;
this.mRenderComponent =
new engine.SpriteRenderable(spriteTexture);
this.mRenderComponent.setColor([1, 1, 1, 0.1]);
this.mRenderComponent.getXform().setPosition(50, 33);
this.mRenderComponent.getXform().setSize(
this.kRefWidth / 50, this.kRefHeight / 50);
this.mRenderComponent.setElementPixelPositions(510,595,23,153);
}
}
export default DyePack;
注意,即使没有特定的行为,DyePack
也在实现曾经在MyGame
级别的init()
函数中找到的代码。这样,DyePack
对象隐藏了特定的几何信息,简化了MyGame
层次。
Note
从引擎访问文件index.js
导入的需要,几乎适用于所有的客户端源代码文件,在此不再赘述。
英雄游戏对象
Hero
类支持直接的用户键盘控制。这个对象演示了从MyGame
的update()
函数中隐藏游戏对象控制逻辑。
-
在
src/my_game/objects
文件夹中创建一个新文件,并将其命名为hero.js
。将Hero
定义为GameObject
的子类,并实现构造函数来初始化 sprite UV 值、大小和位置。请确保导出并共享此类。 -
添加一个函数来支持通过用户键盘控制来更新这个对象。
Hero
对象根据键盘的 WASD 输入以kDelta
速率移动。
class Hero extends engine.GameObject {
constructor(spriteTexture) {
super(null);
this.kDelta = 0.3;
this.mRenderComponent =
new engine.SpriteRenderable(spriteTexture);
this.mRenderComponent.setColor([1, 1, 1, 0]);
this.mRenderComponent.getXform().setPosition(35, 50);
this.mRenderComponent.getXform().setSize(9, 12);
this.mRenderComponent.setElementPixelPositions(0, 120, 0, 180);
}
... implementation to follow ...
export default Hero;
update() {
// control by WASD
let xform = this.getXform();
if (engine.input.isKeyPressed(engine.input.keys.W)) {
xform.incYPosBy(this.kDelta);
}
if (engine.input.isKeyPressed(engine.input.keys.S)) {
xform.incYPosBy(-this.kDelta);
}
if (engine.input.isKeyPressed(engine.input.keys.A)) {
xform.incXPosBy(-this.kDelta);
}
if (engine.input.isKeyPressed(engine.input.keys.D)) {
xform.incXPosBy(this.kDelta);
}
}
迷你游戏对象
Minion
类演示了简单的自主行为也可以被隐藏:
-
在
src/my_game/objects
文件夹中创建一个新文件,并将其命名为minion.js
。将Minion
定义为GameObject
的子类,并实现构造函数来初始化精灵 UV 值、精灵动画参数、大小和位置,如下所示: -
添加一个函数来更新 sprite 动画,支持简单的从右向左移动,并提供包装功能:
class Minion extends engine.GameObject {
constructor(spriteTexture, atY) {
super(null);
this.kDelta = 0.2;
this.mRenderComponent =
new engine.SpriteAnimateRenderable(spriteTexture);
this.mRenderComponent.setColor([1, 1, 1, 0]);
this.mRenderComponent.getXform().setPosition(
Math.random() * 100, atY);
this.mRenderComponent.getXform().setSize(12, 9.6);
// first element pixel position: top-left 512 is top of image
// 0 is left of the image
this.mRenderComponent.setSpriteSequence(512, 0,
204, 164, // width x height in pixels
5, // number of elements in this sequence
0); // horizontal padding in between
this.mRenderComponent.setAnimationType(
engine.eAnimationType.eSwing);
this.mRenderComponent.setAnimationSpeed(15);
// show each element for mAnimSpeed updates
}
... implementation to follow ...
}
export default Minion;
update() {
// remember to update this.mRenderComponent's animation
this.mRenderComponent.updateAnimation();
// move towards the left and wraps
let xform = this.getXform();
xform.incXPosBy(-this.kDelta);
// if fly off to the left, re-appear at the right
if (xform.getXPos() < 0) {
xform.setXPos(100);
xform.setYPos(65 * Math.random());
}
}
我的游戏场景
和所有情况一样,MyGame
级别在my_game.js
文件中实现。定义了三个特定的GameObject
子类后,按照以下步骤操作:
- 除了引擎访问文件
index.js
,为了访问新定义的对象,必须导入相应的源代码:
import engine from "../engine/index.js";
// user stuff
import DyePack from "./objects/dye_pack.js";
import Minion from "./objects/minion.js";
import Hero from "./objects/hero.js";
Note
与其他导入/导出报表的情况一样,除非有其他特定原因,否则不会再次显示此提醒。
-
构造函数和
load()
、unload()
以及draw()
函数与前面项目中的类似,所以这里不显示细节。 -
编辑
init()
函数并添加以下代码:
init() {
... identical to previous code ...
// Step B: The dye pack: simply another GameObject
this.mDyePack = new DyePack(this.kMinionSprite);
// Step C: A set of Minions
this.mMinionset = new engine.GameObjectSet();
let i = 0, randomY, aMinion;
// create 5 minions at random Y values
for (i = 0; i < 5; i++) {
randomY = Math.random() * 65;
aMinion = new Minion(this.kMinionSprite, randomY);
this.mMinionset.addToSet(aMinion);
}
// Step D: Create the hero object
this.mHero = new Hero(this.kMinionSprite);
// Step E: Create and initialize message output
this.mMsg = new engine.FontRenderable("Status Message");
this.mMsg.setColor([0, 0, 0, 1]);
this.mMsg.getXform().setPosition(1, 2);
this.mMsg.setTextHeight(3);
}
步骤 A 的细节,即相机的创建和背景色的初始化,没有显示出来,因为它们与前面的项目相同。步骤 B、C 和 D 显示了三种对象类型的实例化,步骤 C 显示了从右向左移动的Minion
对象的创建和插入到mMinionset
中,这是GameObjectSet
类的一个实例。请注意,init()
函数不需要设置每个对象的纹理、几何图形等。
- 编辑
update()
功能更新游戏状态:
update() {
this.mHero.update();
this.mMinionset.update();
this.mDyePack.update();
}
抽象出每个对象类型的明确定义的行为后,clean update()
函数清楚地显示出游戏由三个不交互的对象组成。
观察
现在你可以运行这个项目了,你会注意到六个小喽啰稍微复杂一点的动作是用更加简洁的init()
和update()
函数完成的。init()
功能仅包括在游戏世界中放置创建的对象的逻辑和控制,不包括不同对象类型的任何特定设置。随着Minion
对象在它自己的update()
函数中定义它的运动行为,MyGame update()
函数中的逻辑可以关注关卡的细节。注意,这个函数的结构清楚地表明,这三个对象是独立更新的,彼此之间没有交互。
Note
在本书中,几乎在所有情况下,MyGame
类都是为了展示引擎功能而设计的。因此,大多数MyGame
类中的源代码组织可能并不代表实现游戏的最佳实践。
创建追逐行为
对前一个项目的进一步研究表明,尽管有相当多的小黄人在屏幕上移动,但他们的动作简单而乏味。即使在速度和方向上有变化,这些动作也没有目的,也不知道场景中的其他游戏对象。为了支持更复杂或有趣的运动,GameObject
需要知道其他物体的位置,并根据这些信息确定运动。
追逐行为就是这样一个例子。追逐对象的目标通常是抓住它所瞄准的游戏对象。这需要对追踪者的前方方向和速度进行程序化的操作,这样它就能锁定目标。然而,通常重要的是避免实现一个具有完美目标并且总是击中目标的追逐者——因为如果玩家无法避免被击中,游戏将变得不可能的困难。尽管如此,这并不意味着如果你的游戏设计需要的话,你不应该实现一个完美的追逐者。您将在下一个项目中实现一个追赶器。
向量和相关操作是实现对象运动和行为的基础。在用向量编程之前,先快速回顾一下。与矩阵和变换运算符的情况一样,下面的讨论并不意味着对向量的全面覆盖。相反,重点是应用与游戏引擎实现相关的少量概念。这不是对数学背后的理论的研究。如果你对向量的细节以及它们与游戏的关系感兴趣,请参考第一章中的讨论,在那里你可以通过钻研线性代数和游戏的相关书籍来深入了解这些主题。
媒介评论
向量被用于许多研究领域,包括数学、物理、计算机科学和工程。它们在游戏中特别重要;几乎每个游戏都以这样或那样的方式使用向量。因为它们被广泛使用,这一节将致力于理解和利用游戏中的向量。
Note
对于向量的介绍和全面覆盖,可以参考 www.storyofmathematics.com/vectors
。关于游戏中矢量应用的更详细报道,可以参考【Unity 3D 游戏开发的基础数学:数学基础初学者指南,Apress,2019。
向量最常见的用途之一是表示物体的位移和方向或速度。这很容易做到,因为向量是由其大小和方向定义的。仅使用这少量信息,您就可以表示物体的速度或加速度等属性。如果你有一个物体的位置、方向和速度,那么你就有足够的信息在游戏世界中移动它,而不需要用户输入。
在进一步讨论之前,回顾一下向量的概念是很重要的,从如何定义向量开始。可以使用两点来指定向量。例如给定任意位置Pa=(xa,y**a)和Pb=(xb, y 可以将从 P a 到 P b 或的向量定义为Pb—Pa。 你可以在下面的等式和图 6-3 中看到这一点:
图 6-3
由两点定义的向量
-
PT3a=(xa, y a )
-
Pb=(xb, y b )
-
现在您有了一个向量,您可以很容易地确定它的长度(或大小)和方向。向量的长度等于创建它的两点之间的距离。在这个例子中,
的长度等于 P * a 和 P b 之间的距离,而
的方向则是从 P a 朝向 P b * 。
Note
向量的大小通常被称为其长度或大小。
在gl-matrix
库中,vec2
对象实现了 2D 向量的功能。方便的是,你也可以使用vec2
物体来代表空间中的 2D 点或位置。在前面的例子中, P a , P b ,都可以实现为
vec2
对象的实例。然而,是数学上唯一定义的向量。 P * a 和 P b * 代表用来创建矢量的位置或点。
回想一下,向量也可以归一化。一个归一化的向量(也称为单位向量)的大小总是为 1。通过下面的函数可以看到一个归一化的矢量,如图 6-4 所示。注意,常规向量的数学符号是,归一化向量的数学符号是
:
图 6-4
被标准化的向量
:归一化矢量
,并将结果存储到
vec2
对象
到一个位置的向量也可以旋转。例如,如果矢量表示从原点到位置的方向( x * v , y v ),你想把它旋转θ,那么,如图 6-5 所示,你可以用下面的等式导出 x r 和y*
*
图 6-5
从原点到位置( x v , y v )旋转角度θ的向量
-
x【r】=【v】【θ】**
-
和【r】=【v】【sin】
*Note
JavaScript 三角函数,包括Math.sin()
和Math.cos()
函数,假设输入是弧度而不是角度。回想一下,1 度等于弧度。
记住向量是由它们的方向和大小定义的,这一点很重要。换句话说,两个向量可以彼此相等,而与向量的位置无关。图 6-6 显示了两个矢量和
,它们位于不同的位置,但方向和大小相同,因此相等。相比之下,矢量
并不相同,因为它的方向和大小与其他矢量不同。
图 6-6
2D 空间中的三个向量,其中两个向量相等
点积
两个归一化向量的点积为您提供了一种方法,可以找到这两个向量之间的角度。例如,假设如下:
那么下面是真的:
。
此外,如果向量和
都被归一化,则
图 6-7 描绘了和
向量之间有一个角度 θ 的例子。同样重要的是要认识到,如果
,那么这两个向量是垂直的。
图 6-7
两个向量之间的角度,可以通过点积找到
Note
如果需要复习或刷新点积的概念,请参考 www.mathsisfun.com/algebra/vectors-dot-product.html
。
叉积
两个向量的叉积产生一个与两个原始向量正交的向量。在 2D 游戏中,2D 维度*放在屏幕上,叉积的结果是一个指向内(朝向屏幕)或外(远离屏幕)的向量。这可能看起来很奇怪,因为在 2D 或 x/y *面上交叉两个向量会产生一个位于第三维或沿 z 轴的向量,这并不直观。然而,在第三维空间中产生的矢量携带着重要的信息。例如,这个向量在第三维中的方向可以用来确定游戏对象需要顺时针还是逆时针方向旋转。仔细看看以下内容:
鉴于上述情况,以下情况属实:
是垂直于
和
的向量。
此外,您知道 x/y *面上两个向量的叉积会产生 z 方向的向量。当时,你知道
是从
顺时针方向,同样,当
时,你知道
是逆时针方向。图 6-8 应该有助于澄清这个概念。
图 6-8
两个向量的叉积
Note
如果需要回顾或刷新交叉产品的概念,请参考 www.mathsisfun.com/algebra/vectors-cross-product.html
。
前沿与追逐项目
这个项目实现了更有趣、更复杂的行为,这些行为基于已经被回顾过的向量概念。你将体验定义和改变一个对象的正面方向,并引导一个对象在场景中追逐另一个对象的过程,而不是恒定和无目的的运动。你可以在图 6-9 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.2.front_and_chase
文件夹中定义。
图 6-9
运行前端和追踪项目
该项目的控制措施如下:
-
WASD 键:移动
Hero
对象 -
左/右箭头键:在用户控制下改变
Brain
对象的前方方向 -
上下箭头键:增加/减少
Brain
物体的速度 -
H 键:将
Brain
对象切换到用户箭头键控制下 -
J 键:切换
Brain
对象始终指向并向当前Hero
对象位置移动 -
K 键:切换
Brain
物体转向并逐渐向当前Hero
物体位置移动
该项目的目标如下:
-
体验工作的速度和方向
-
练习沿着预先定义的方向行进
-
用矢量点积和叉积实现算法
-
检查和实施追逐行为
您可以在assets
文件夹中找到与上一个项目相同的外部资源文件。
将矢量旋转添加到 gl 矩阵库中
gl-matrix
库不支持旋转 2D 空间中的位置。这可以通过将以下代码添加到lib
文件夹中的gl-matrix.js
文件来纠正:
vec2.rotate = function(out, a, c){
var r=[];
// perform rotation
r[0] = a[0]*Math.cos(c) - a[1]*Math.sin(c);
r[1] = a[0]*Math.sin(c) + a[1]*Math.cos(c);
out[0] = r[0];
out[1] = r[1];
return r;
};
Note
从现在开始,对gl-matrix
库的修改必须出现在所有项目中。
修改游戏对象以支持有趣的行为
GameObject
类抽象并实现所需的新对象行为:
-
编辑
game_object.js
文件并修改GameObject
构造函数以定义可见度、前方方向和速度: -
为实例变量添加评估器和设置器函数:
constructor(renderable) {
this.mRenderComponent = renderable;
this.mVisible = true;
this.mCurrentFrontDir = vec2.fromValues(0, 1); // front direction
this.mSpeed = 0;
}
- 执行一个功能,将前方旋转到一个位置,
p
:
getXform() { return this.mRenderComponent.getXform(); }
setVisibility(f) { this.mVisible = f; }
isVisible() { return this.mVisible; }
setSpeed(s) { this.mSpeed = s; }
getSpeed() { return this.mSpeed; }
incSpeedBy(delta) { this.mSpeed += delta; }
setCurrentFrontDir(f) { vec2.normalize(this.mCurrentFrontDir, f); }
getCurrentFrontDir() { return this.mCurrentFrontDir; }
getRenderable() { return this.mRenderComponent; }
rotateObjPointTo(p, rate) {
// Step A: determine if reached the destination position p
let dir = [];
vec2.sub(dir, p, this.getXform().getPosition());
let len = vec2.length(dir);
if (len < Number.MIN_VALUE) {
return; // we are there.
}
vec2.scale(dir, dir, 1 / len);
// Step B: compute the angle to rotate
let fdir = this.getCurrentFrontDir();
let cosTheta = vec2.dot(dir, fdir);
if (cosTheta > 0.999999) { // almost exactly the same direction
return;
}
// Step C: clamp the cosTheta to -1 to 1
// in a perfect world, this would never happen! BUT ...
if (cosTheta > 1) {
cosTheta = 1;
} else {
if (cosTheta < -1) {
cosTheta = -1;
}
}
// Step D: compute whether to rotate clockwise, or counterclockwise
let dir3d = vec3.fromValues(dir[0], dir[1], 0);
let f3d = vec3.fromValues(fdir[0], fdir[1], 0);
let r3d = [];
vec3.cross(r3d, f3d, dir3d);
let rad = Math.acos(cosTheta); // radian to roate
if (r3d[2] < 0) {
rad = -rad;
}
// Step E: rotate the facing direction with the angle and rate
rad *= rate; // actual angle need to rotate from Obj's front
vec2.rotate(this.getCurrentFrontDir(),this.getCurrentFrontDir(),rad);
this.getXform().incRotationByRad(rad);
}
rotateObjPointTo()
功能以参数rate
指定的速率旋转mCurrentFrontDir
指向目的位置p
。以下是每个操作的详细信息:
图 6-10
一个游戏对象(Brain
)追逐一个目标(Hero
)
-
步骤 A 计算当前对象和目的位置
p
之间的距离。如果该值很小,则意味着当前对象和目标位置很接*。函数返回,不做进一步处理。 -
步骤 B,如图 6-10 所示,计算点积,确定物体当前前方方向(
fdir
)与朝向目的位置方向p
(dir
)之间的角度θ。如果这两个向量指向相同的方向(cosθ几乎为 1 或θ几乎为零),则函数返回。 -
添加一个函数,用物体的方向和速度更新物体的位置。注意,如果
mCurrentFrontDir
被rotateObjPointTo()
函数修改,那么这个update()
函数将把对象移向目标位置p
,对象将表现得好像在追逐目标。 -
步骤 C 检查
cosTheta
的范围。由于 JavaScript 中浮点运算的不准确性,这是必须执行的步骤。 -
步骤 D 使用叉积的结果来确定当前的
GameObject
应该顺时针还是逆时针转动以朝向目的位置p
。 -
步骤 E 旋转
mCurrentFrontDir
并在Renderable
对象的Transform
中设置旋转。识别两个独立的对象旋转控件非常重要。Transform
控制被画物体的旋转,mCurrentFrontDir
控制行进方向。在这种情况下,两者是同步的,因此必须同时用新值更新。 -
添加一个基于可见性设置绘制对象的函数:
update() {
// simple default behavior
let pos = this.getXform().getPosition();
vec2.scaleAndAdd(pos, pos,this.getCurrentFrontDir(),this.getSpeed());
}
draw(aCamera) {
if (this.isVisible()) {
this.mRenderComponent.draw(aCamera);
}
}
测试追踪功能
这个测试用例的策略和目标是创建一个可操纵的Brain
对象来演示沿着预定义的前方方向行进,并引导Brain
去追逐Hero
来演示追逐功能。
定义大脑游戏对象
Brain
对象将在用户左/右箭头键的控制下沿其前方方向移动,以进行转向:
-
在
src/my_game/objects
文件夹中创建一个新文件,并将其命名为brain.js
。将Brain
定义为GameObject
的子类,实现构造函数初始化外观和行为参数。 -
超越
update()
功能,支持用户转向和控制速度。注意,必须调用GameObject
中默认的update()
函数,以支持物体根据其速度沿前方的基本移动。
class Brain extends engine.GameObject {
constructor(spriteTexture) {
super(null);
this.kDeltaDegree = 1;
this.kDeltaRad = Math.PI * this.kDeltaDegree / 180;
this.kDeltaSpeed = 0.01;
this.mRenderComponent =
new engine.SpriteRenderable(spriteTexture);
this.mRenderComponent.setColor([1, 1, 1, 0]);
this.mRenderComponent.getXform().setPosition(50, 10);
this.mRenderComponent.getXform().setSize(3, 5.4);
this.mRenderComponent.setElementPixelPositions(600, 700, 0, 180);
this.setSpeed(0.05);
}
... implementation to follow ...
}
export default Brain;
update() {
super.update();
let xf = this.getXform();
let fdir = this.getCurrentFrontDir();
if (engine.input.isKeyPressed(engine.input.keys.Left)) {
xf.incRotationByDegree(this.kDeltaDegree);
vec2.rotate(fdir, fdir, this.kDeltaRad);
}
if (engine.input.isKeyPressed(engine.input.keys.Right)) {
xf.incRotationByRad(-this.kDeltaRad);
vec2.rotate(fdir, fdir, -this.kDeltaRad);
}
if (engine.input.isKeyClicked(engine.input.keys.Up)) {
this.incSpeedBy(this.kDeltaSpeed);
}
if (engine.input.isKeyClicked(engine.input.keys.Down)) {
this.incSpeedBy(-this.kDeltaSpeed);
}
}
我的游戏场景
修改MyGame
场景来测试Brain
物体的移动。在这种情况下,除了update()
函数之外,my_game.js
中的其余源代码与之前的项目类似。因此,只显示了update()
功能的细节:
update() {
let msg = "Brain [H:keys J:imm K:gradual]: ";
let rate = 1;
this.mHero.update();
switch (this.mMode) {
case 'H':
this.mBrain.update(); // player steers with arrow keys
break;
case 'K':
rate = 0.02; // gradual rate
// In gradual mode, the following should also be executed
case 'J':
this.mBrain.rotateObjPointTo(
this.mHero.getXform().getPosition(), rate);
// the default GameObject: only move forward
engine.GameObject.prototype.update.call(this.mBrain);
break;
}
if (engine.input.isKeyClicked(engine.input.keys.H)) {
this.mMode = 'H';
}
if (engine.input.isKeyClicked(engine.input.keys.J)) {
this.mMode = 'J';
}
if (engine.input.isKeyClicked(engine.input.keys.K)) {
this.mMode = 'K';
}
this.mMsg.setText(msg + this.mMode);
}
在update()
函数中,switch
语句使用mMode
来决定如何更新Brain
对象。在J
和K
模式下,Brain
对象通过rotateObjPointTo()
函数调用转向Hero
对象位置。在H
模式下,调用Brain
对象的update()
函数,让用户用箭头键操纵对象。最后三个if
语句只是根据用户输入设置mMode
变量。
注意在J
和K
模式下,为了绕过rotateObjPointTo()
后的用户控制逻辑,被调用的update()
函数是由GameObject
定义的函数,而不是由Brain
定义的函数。
Note
JavaScript 语法ClassName.prototype.FunctionName.call(anObj)
调用由ClassName
定义的FunctionName
,其中anObj
是ClassName
的子类。
观察
您现在可以尝试运行该项目。最初,Brain
对象处于用户的控制之下。您可以使用左箭头键和右箭头键来改变Brain
对象的前方方向,并体验操纵该对象。按下J
键会导致Brain
对象立即指向并移向Hero
对象。这是默认转弯rate
值为 1.0 的结果。K
键导致更自然的行为,其中Brain
对象继续向前移动,并逐渐转向向Hero
对象移动。随意更改rate
变量的值或修改Brain
对象的控制值。例如,更改kDeltaRad
或kDeltaSpeed
来试验不同的行为设置。
游戏对象之间的碰撞
在之前的项目中,Brain
物体永远不会停止移动。注意在J
和K
模式下,Brain
物体到达目标位置时会绕轨道运行或快速翻转方向。Brain
物体失去了检测到它与Hero
物体相撞的关键能力,因此,它永远不会停止移动。本节描述了轴对齐包围盒(AABBs),它是用于*似物体碰撞的最直接的工具之一,并演示了基于 AABB 的碰撞检测的实现。
轴对齐的边界框(AABB)
AABB 是一个 x/y 轴对齐的矩形框,它限定了给定对象的边界。术语 x/y 轴对齐是指 AABB 的四条边*行于水* x 轴或垂直 y 轴。图 6-11 显示了一个用左下角(mLL
)、宽度和高度表示Hero
对象边界的例子。这是表示 AABB 的一种相当常见的方式,因为它仅使用一个位置和两个浮点数来表示维度。
图 6-11
对象边界的左下角和大小
有趣的是,除了表示对象的边界,边界框还可以用来表示任何给定矩形区域的边界。例如,回想一下通过Camera
可见的 WC 是一个矩形区域,相机的位置位于中心,WC 的宽度/高度由游戏开发者定义。可以定义一个 AABB 来表示可见的 WC 矩形区域或 WC 窗口,并用于检测 WC 窗口和游戏世界中的GameObject
实例之间的碰撞。
Note
在本书中,AABB 和“边界框”可以互换使用。
边界框和碰撞项目
这个项目演示了如何为一个GameObject
实例定义一个边界框,并根据它们的边界框检测两个GameObject
实例之间的冲突。重要的是要记住,边界框是轴对齐的,因此,本节介绍的解决方案不支持旋转对象之间的碰撞检测。你可以在图 6-12 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.3.bbox_and_collisions
文件夹中定义。
图 6-12
运行边界框和碰撞项目
该项目的控件与之前的项目相同:
-
WASD 键:移动
Hero
对象 -
左/右箭头键:在用户控制下改变
Brain
对象的前方方向 -
上下箭头键:增加/减少
Brain
物体的速度 -
H 键:将
Brain
对象切换到用户箭头键控制下 -
J 键:切换
Brain
对象始终指向并向当前Hero
对象位置移动 -
K 键:切换
Brain
物体转向并逐渐向当前Hero
物体位置移动
该项目的目标如下:
-
理解边界框类的实现
-
体验使用
GameObject
实例的边界框 -
计算并使用
Camera
WC 窗口的边界 -
使用对象碰撞和对象与摄影机 WC 窗口碰撞进行编程
您可以在assets
文件夹中找到与上一个项目相同的外部资源文件。
定义一个边界框类
定义一个BoundingBox
类来表示矩形区域的边界:
- 在
src/engine
文件夹中新建一个文件;命名为bounding_box.js
。首先,定义一个枚举数据类型,其值标识边界框的冲突边。
const eBoundCollideStatus = Object.freeze({
eCollideLeft: 1,
eCollideRight: 2,
eCollideTop: 4,
eCollideBottom: 8,
eInside: 16,
eOutside: 0
});
注意,每个枚举值只有一个非零位。这允许枚举值与按位“或”操作符组合来表示多边冲突。例如,如果一个对象同时与一个边界框的顶部和左侧发生碰撞,碰撞状态将为eCollideLeft | eCollideTop = 1 | 4 = 5
。
-
现在,用实例变量定义
BoundingBox
类和构造函数来表示一个边界,如图 6-11 所示。注意,eBoundCollideStatus
也必须被导出,这样引擎的其他部分,包括客户端,也可以访问。 -
setBounds()
函数计算并设置边界框的实例变量:
class BoundingBox {
constructor(centerPos, w, h) {
this.mLL = vec2.fromValues(0, 0);
this.setBounds(centerPos, w, h);
}
... implementation to follow ...
}
export {eBoundCollideStatus}
export default BoundingBox;
- 定义一个函数来确定给定位置(x,y)是否在框的边界内:
setBounds(centerPos, w, h) {
this.mWidth = w;
this.mHeight = h;
this.mLL[0] = centerPos[0] - (w / 2);
this.mLL[1] = centerPos[1] - (h / 2);
}
- 定义一个函数来确定给定边界是否与当前边界相交:
containsPoint(x, y) {
return ((x > this.minX()) && (x < this.maxX()) &&
(y > this.minY()) && (y < this.maxY()));
}
- 定义一个函数来计算给定边界和当前边界之间的相交状态:
intersectsBound(otherBound) {
return ((this.minX() < otherBound.maxX()) &&
(this.maxX() > otherBound.minX()) &&
(this.minY() < otherBound.maxY()) &&
(this.maxY() > otherBound.minY()));
}
boundCollideStatus(otherBound) {
let status = eBoundCollideStatus.eOutside;
if (this.intersectsBound(otherBound)) {
if (otherBound.minX() < this.minX()) {
status |= eBoundCollideStatus.eCollideLeft;
}
if (otherBound.maxX() > this.maxX()) {
status |= eBoundCollideStatus.eCollideRight;
}
if (otherBound.minY() < this.minY()) {
status |= eBoundCollideStatus.eCollideBottom;
}
if (otherBound.maxY() > this.maxY()) {
status |= eBoundCollideStatus.eCollideTop;
}
// if the bounds intersects and yet none of the sides overlaps
// otherBound is completely inside thisBound
if (status === eBoundCollideStatus.eOutside) {
status = eBoundCollideStatus.eInside;
}
}
return status;
}
请注意intersectsBound()
和boundCollideStatus()
函数之间微妙而重要的区别,前者只能返回一个true
或false
条件,而后者在返回的status
中对冲突双方进行编码。
- 实现将 X/Y 值返回到边界框的最小和最大边界的函数:
minX() { return this.mLL[0]; }
maxX() { return this.mLL[0] + this.mWidth; }
minY() { return this.mLL[1]; }
maxY() { return this.mLL[1] + this.mHeight; }
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
使用引擎中的边界框
新定义的功能将用于检测对象之间以及对象和 WC 边界之间的碰撞。为了实现这一点,必须修改GameObject
和Camera
类。
-
编辑
game_object.js
以导入新定义的功能并修改GameObject
类;实现getBBox()
函数返回未旋转的Renderable
对象的边界框: -
编辑
camera.js
从边界框导入,修改Camera
类计算Transform
对象(通常在Renderable
对象中定义)边界和 WC 窗口边界之间的碰撞状态:
import BoundingBox from "../bounding_box.js";
class GameObject {
... identical to previous code ...
getBBox() {
let xform = this.getXform();
let b = new BoundingBox(
xform.getPosition(),
xform.getWidth(),
xform.getHeight());
return b;
}
... identical to previous code ...
}
import BoundingBox from "./bounding_box.js";
class Camera {
... identical to previous code ...
collideWCBound(aXform, zone) {
let bbox = new BoundingBox(
aXform.getPosition(),
aXform.getWidth(),
aXform.getHeight());
let w = zone * this.getWCWidth();
let h = zone * this.getWCHeight();
let cameraBound = new BoundingBox(this.getWCCenter(), w, h);
return cameraBound.boundCollideStatus(bbox);
}
}
请注意,zone
参数定义了应该在碰撞计算中使用的 WC 的相对大小。例如,zone
值为 0.8 意味着根据当前 WC 窗口大小的 80%计算交叉点状态。图 6-13 显示了相机如何与物体碰撞。
图 6-13
Camera
WC 边界与定义一个Transform
对象的边界冲突
用我的游戏测试边界框
这个测试用例的目标是验证在检测对象-对象和对象-摄像机相交时边界框实现的正确性。同样,除了update()
函数之外,my_game.js
文件中的大部分代码与前面的项目相似,这里不再重复。update()
函数是对之前项目的修改,用来测试边界框的交叉点。
update() {
... identical to previous code ...
switch (this.mMode) {
case 'H':
this.mBrain.update(); // player steers with arrow keys
break;
case 'K':
rate = 0.02; // gradual rate
// no break here on purpose
case 'J':
// stop the brain when it touches hero bound
if (!hBbox.intersectsBound(bBbox)) {
this.mBrain.rotateObjPointTo(
this.mHero.getXform().getPosition(), rate);
// the default GameObject: only move forward
engine.GameObject.prototype.update.call(this.mBrain);
}
break;
}
// Check for hero going outside 80% of the WC Window bound
let status = this.mCamera.collideWCBound(this.mHero.getXform(), 0.8);
... identical to previous code ...
this.mMsg.setText(msg + this.mMode + " [Hero bound=" + status + "]");
}
在switch
语句的J
和K
情况下,在调用Brain.rotateObjPointTo()
和update()
导致追逐行为之前,修改测试Brain
和Hero
对象之间的包围盒碰撞。这样,Brain
对象一碰到Hero
对象的边界就会停止移动。此外,计算并显示Hero
对象和 80%的摄像机 WC 窗口之间的碰撞结果。
观察
现在,您可以运行项目并观察到Brain
对象在自主模式(J 或 K 键)下,一接触到Hero
对象就停止移动。当你四处移动Hero
对象时,在Hero
对象实际接触 WC 窗口边界之前,观察到Hero bound
输出消息开始回应 WC 窗口碰撞。这是传递给mCamera.collideWCBound()
函数的参数 0.8 或 80%的结果,将碰撞计算配置为当前 WC 窗口大小的 80%。当Hero
对象完全在 WC 窗口边界的 80%以内时,输出Hero
bound
值为 16 或eboundcollideStatus.eInside
的值。试着移动Hero
物体接触窗口边界的顶部 20 %,观察Hero bound
值 4 或eboundcollideStatus.eCollideTop
值。现在将Hero
对象移向窗口的左上角,观察Hero bound
值 5 或eboundcollideStatus.eCollideTop | eboundcollideStatus.eCollideLeft
。这样,碰撞状态是所有碰撞边界的按位或结果。
每像素碰撞
在前面的示例中,您看到了边界框碰撞*似的结果。也就是说,Brain
对象的运动一与Hero
对象的边界重叠就停止。这比最初的情况有了很大的改进,在最初的情况下,Brain
物体永远不会停止移动。然而,如图 6-14 所示,基于边界框的碰撞有两个严重的限制。
图 6-14
基于边界框的碰撞限制
-
在前一个例子中引入的
BoundingBox
对象不考虑旋转。这是 AABB 的一个众所周知的限制:尽管这种方法计算效率高,但它不支持旋转的对象。 -
这两个物体实际上没有碰撞。两个物体的边界重叠的事实并不自动等同于两个物体的碰撞。
在本项目中,您将实现逐像素精确碰撞检测、逐像素精确碰撞检测或逐像素碰撞检测,以检测两个碰撞对象的不透明像素的重叠。然而,请记住,这是而不是一个终极解决方案。虽然每像素碰撞检测是精确的,但代价是潜在的性能成本。随着图像变得越来越大和越来越复杂,它也有更多的像素需要进行碰撞检查。这与包围盒碰撞检测所需的恒定计算成本形成对比。
每像素碰撞项目
这个项目演示了如何检测一个大的纹理对象,即Collector
minion 和一个小的纹理对象,即Portal
minion 之间的碰撞。这两种纹理都包含透明和不透明区域。只有当不透明像素重叠时,才会发生碰撞。在这个项目中,当碰撞发生时,一个黄色的DyePack
出现在碰撞点。你可以在图 6-15 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.4.per_pixel_collisions
文件夹中定义。
图 6-15
运行逐像素碰撞项目
该项目的控制措施如下:
-
箭头键:移动小纹理对象,
Portal
宠臣 -
WASD 键:移动大型纹理对象,
Collector
爪牙
该项目的目标如下:
-
演示如何检测不透明像素重叠
-
为了理解使用逐像素精确碰撞检测的优点和缺点
Note
“透明”像素是一个你可以完全看透的像素,在这个引擎中,它的 alpha 值为 0。“不透明”像素的 alpha 值大于 0,或者该像素没有完全遮挡其后面的内容;它可能闭塞也可能不闭塞。“不透明”像素会遮挡其后面的内容,是“不透明的”,alpha 值为 1。例如,请注意您可以“透视”到Portal
对象的顶部区域。这些像素是不透明的,但也不是不透明的,当根据项目定义的参数发生重叠时,应该会导致冲突。
您可以在assets
文件夹中找到以下外部资源:包含默认系统字体的fonts
文件夹、minion_collector.png
、minion_portal.png
和minion_sprite.png
。注意minion_collector.png
是大的,1024x1024 的图像,而minion_portal.png
是小的,64x64 的图像;minion_sprite.png
定义DyePack
sprite 元素。
逐像素碰撞算法概述
在继续之前,确定检测两个纹理对象之间的碰撞的要求很重要。最重要的是,纹理本身需要包含一个透明区域,以便这种类型的碰撞检测能够提高精确度。如果纹理中没有透明度,您可以并且应该使用简单的边界框碰撞检测。如果一个或两个纹理包含透明区域,那么你需要处理两种碰撞情况。第一种情况是检查两个对象的边界是否冲突。你可以在图 6-16 中看到这一点。请注意对象的边界是如何重叠的,然而没有一个不透明的彩色像素相接触。
图 6-16
没有实际碰撞的重叠边界框
下一种情况是检查纹理的不透明像素是否重叠。看一下图 6-17 。来自Collector
和Portal
对象纹理的不透明像素清楚地彼此接触。
图 6-17
大纹理和小纹理之间发生像素冲突
既然问题已经明确定义,下面是每像素精确碰撞检测的逻辑或伪代码:
Given two images, Image-A and Image-B
If the bounds of the two collide then
For each Pixel-A in Image-A
If Pixel-A is not completely transparent
pixelCameraSpace = Pixel-A position in camera space
Transform pixelCameraSpace to Image-B space
Read Pixel-B from Image-B
If Pixel-B is not completely transparent then
A collision has occurred
需要从pixelCameraSpace
到 Image-B 空间的逐像素转换,因为碰撞检查必须在相同的坐标空间内进行。
请注意,在算法中,图像 A 和图像 B 是可交换的。也就是说,当测试两个图像之间的冲突时,哪个图像是图像 A 还是图像 b 并不重要。冲突结果将是相同的。这两幅图像要么重叠,要么不重叠。另外,注意这个算法的运行时间。必须处理图像 A 中的每个像素;因此,运行时间是 O(N),其中 N 是 Image-A 或 Image-A 的分辨率中的像素数。出于这个原因,出于性能原因,选择两个图像中较小的一个(本例中为Portal
minion)作为 Image-A 是很重要的。
此时,您可能会明白为什么像素精确碰撞检测的性能令人担忧。在每次更新许多高分辨率纹理时检查这些碰撞会很快降低性能。现在,您可以检查每像素精确碰撞的实现了。
修改纹理以颜色数组的形式加载纹理
回想一下,Texture
组件从服务器文件系统读取图像文件,将图像加载到 GPU 内存,并将图像处理成 WebGL 纹理。通过这种方式,纹理图像存储在 GPU 上,并且不能被运行在 CPU 上的游戏引擎访问。为了支持逐像素碰撞检测,必须从 GPU 中检索颜色信息,并将其存储在 CPU 中。可以修改Texture
组件来支持这个需求。
-
在
texture.js
文件中,扩展TextureInfo
对象以包含一个新变量,用于存储文件纹理的颜色数组: -
定义并导出从 GPU 内存中检索颜色数组的函数:
class TextureInfo {
constructor(w, h, id) {
this.mWidth = w;
this.mHeight = h;
this.mGLTexID = id;
this.mColorArray = null;
}
}
function getColorArray(textureName) {
let gl = glSys.get();
let texInfo = get(textureName);
if (texInfo.mColorArray === null) {
// create framebuffer bind to texture and read the color content
let fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D, texInfo.mGLTexID, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) ===
gl.FRAMEBUFFER_COMPLETE) {
let pixels = new Uint8Array(
texInfo.mWidth * texInfo.mHeight * 4);
gl.readPixels(0, 0, texInfo.mWidth, texInfo.mHeight,
gl.RGBA, gl.UNSIGNED_BYTE, pixels);
texInfo.mColorArray = pixels;
} else {
throw new Error("...");
return null;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.deleteFramebuffer(fb);
}
return texInfo.mColorArray;
}
export {has, get, load, unload,
TextureInfo,
activate, deactivate,
getColorArray
}
getColorArray()
函数创建一个 WebGL FRAMEBUFFER
,用所需的纹理填充缓冲区,并将缓冲区内容检索到由texInfo.mColorArray
引用的 CPU 内存中。
修改 TextureRenderable 以支持逐像素冲突
TextureRenderable
是最适合实现逐像素碰撞功能的类。这是因为TextureRenderable
是所有渲染纹理的类的基类。在这个基类中实现意味着所有子类都可以继承这个功能,只需做很少的额外修改。
随着TextureRenderable
类功能的增加,实现源代码的复杂性和规模也会增加。为了可读性和可扩展性,保持源代码文件的大小很重要。一种有效的方法是根据功能将一个类的源代码分成多个文件。
组织源代码
在下面的步骤中,TextureRenderable
类将被分成三个源代码文件:texture_renderable_main.js
用于实现以前项目的基本功能,texture_renderable_pixel_collision.js
用于实现新引入的每像素精确碰撞,texture_renderable.js
用作类访问点。
-
重命名
texture_renderable.js
。到texture_renderable_main.js
。这个文件定义了TextureRenderable
类的基本功能。 -
在
src/engine/renderables
中创建一个新文件,命名为texture_renderable_pixel_collision.js
。这个文件将用于扩展TextureRenderable
类的功能,以支持每像素精确的碰撞。添加以下代码,从Texture
模块和基本的TextureRenderable
类导入,并重新导出TextureRenderable
类。目前,这个文件没有任何用途;您将在下面的小节中添加适当的扩展函数。 -
通过添加以下代码,创建一个新的
texture_renderable.js
文件作为TextureRenderable
访问点:
"use strict";
import TextureRenderable from "./texture_renderable_main.js";
import * as texture from "../resources/texture.js";
... implementation to follow ...
export default TextureRenderable;
"use strict";
import TextureRenderable from "./ texture_renderable_pixel_collision.js";
export default TextureRenderable;
有了这个结构,texture_renderable_main.js
文件实现了所有的基本功能,并导出到texture_renderable_pixel_collision.js
,后者将附加的功能添加到TextureRenderable
类中。最后,texture_renderable.js
从texture_renderable_pixel_collision.js
导入扩展功能。TextureRenderable
类的用户可以简单地从texture_renderable.js
导入,并且可以访问所有已定义的功能。
这样,从游戏开发者的角度来看,texture_renderable.js
充当了TextureRenderable
类的访问点,隐藏了实现源代码结构的细节。同时,从引擎开发人员的角度来看,复杂的实现被分离到源代码文件中,这些文件的名称表明了实现每个单独文件可读性的内容。
定义对纹理颜色数组的访问
回想一下,您通过首先编辑Texture
模块从 GPU 到 CPU 检索表示纹理的颜色数组来开始这个项目。您现在必须编辑TextureRenderable
才能访问该颜色数组。
-
编辑
texture_renderable_main.js
文件,并修改构造函数以添加实例变量来保存纹理信息,包括对检索到的颜色数组的引用,以支持每像素碰撞检测和以后的子类覆盖: -
修改
setTexture()
函数以相应地初始化实例变量:
class TextureRenderable extends Renderable {
constructor(myTexture) {
super();
// Alpha of 0: switch off tinting of texture
super.setColor([1, 1, 1, 0]);
super._setShader(shaderResources.getTextureShader());
this.mTexture = null;
// these two instance variables are to cache texture information
// for supporting per-pixel accurate collision
this.mTextureInfo = null;
this.mColorArray = null;
// defined for subclass to override
this.mElmWidthPixels = 0;
this.mElmHeightPixels = 0;
this.mElmLeftIndex = 0;
this.mElmBottomIndex = 0;
// texture for this object, cannot be a "null"
this.setTexture(myTexture);
}
setTexture(newTexture) {
this.mTexture = newTexture;
// these two instance variables are to cache texture information
// for supporting per-pixel accurate collision
this.mTextureInfo = texture.get(newTexture);
this.mColorArray = null;
// defined for one sprite element for subclass to override
// For texture_renderable, one sprite element is the entire texture
this.mElmWidthPixels = this.mTextureInfo.mWidth;
this.mElmHeightPixels = this.mTextureInfo.mHeight;
this.mElmLeftIndex = 0;
this.mElmBottomIndex = 0;
}
注意,默认情况下,mColorArry
被初始化为null
。对于 CPU 内存优化,仅对于参与逐像素碰撞的纹理,从 GPU 获取颜色数组。mElmWidthPixels
和mElmHeightPixels
变量是纹理的宽度和高度。这些变量是为以后的子类覆盖定义的,这样算法可以支持 sprite 元素的冲突。
实现逐像素碰撞
现在,您可以在新创建的texture_renderable_pixel_collision.js
文件中实现逐像素碰撞算法了。
- 编辑
texture_renderable_pixel_collision.js
文件,为TextureRenderable
类定义一个新函数来设置mColorArray
:
TextureRenderable.prototype.setColorArray = function() {
if (this.mColorArray === null) {
this.mColorArray = texture.getColorArray(this.mTexture);
}
}
Note
JavaScript 类是基于原型链实现的。在类构造之后,实例方法可以通过类的原型或aClass.prototype.method
来访问和定义。关于 JavaScript 类和原型的更多信息,请参考 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain
。
- 定义一个新函数来返回任何给定像素
(x
、y)
的 alpha 值或透明度:
TextureRenderable.prototype._pixelAlphaValue = function(x, y) {
x = x * 4;
y = y * 4;
return this.mColorArray[(y * this.mTextureInfo.mWidth) + x + 3];
}
注意mColorArray
是一个 1D 数组,其中像素的颜色存储为四个浮点数,并按行组织。
-
定义一个函数来计算给定像素
(i, j)
的 WC 位置(returnWCPos
): -
现在,实现前面函数的逆函数,并使用 WC 位置(
wcPos
)来计算纹理像素索引(returnIndex
):
TextureRenderable.prototype._indexToWCPosition =
function(returnWCPos, i, j) {
let x = i * this.mXform.getWidth() / this.mElmWidthPixels;
let y = j * this.mXform.getHeight() / this.mElmHeightPixels;
returnWCPos[0] = this.mXform.getXPos() +
(x - (this.mXform.getWidth() * 0.5));
returnWCPos[1] = this.mXform.getYPos() +
(y - (this.mXform.getHeight() * 0.5));
}
- 现在可以实现概述的逐像素碰撞算法了:
TextureRenderable.prototype._wcPositionToIndex =
function(returnIndex, wcPos) {
// use wcPos to compute the corresponding returnIndex[0 and 1]
let delta = [];
vec2.sub(delta, wcPos, this.mXform.getPosition());
returnIndex[0] = this.mElmWidthPixels *
(delta[0] / this.mXform.getWidth());
returnIndex[1] = this.mElmHeightPixels *
(delta[1] / this.mXform.getHeight());
// recall that xForm.getPosition() returns center, yet
// Texture origin is at lower-left corner!
returnIndex[0] += this.mElmWidthPixels / 2;
returnIndex[1] += this.mElmHeightPixels / 2;
returnIndex[0] = Math.floor(returnIndex[0]);
returnIndex[1] = Math.floor(returnIndex[1]);
}
TextureRenderable.prototype.pixelTouches = function(other, wcTouchPos) {
let pixelTouch = false;
let xIndex = 0, yIndex;
let otherIndex = [0, 0];
while ((!pixelTouch) && (xIndex < this.mElmWidthPixels)) {
yIndex = 0;
while ((!pixelTouch) && (yIndex < this.mElmHeightPixels)) {
if (this._pixelAlphaValue(xIndex, yIndex) > 0) {
this._indexToWCPosition(wcTouchPos, xIndex, yIndex);
other._wcPositionToIndex(otherIndex, wcTouchPos);
if ((otherIndex[0] >= 0) &&
(otherIndex[0] < other.mElmWidthPixels) &&
(otherIndex[1] >= 0) &&
(otherIndex[1] < other.mElmHeightPixels)) {
pixelTouch = other._pixelAlphaValue(
otherIndex[0], otherIndex[1]) > 0;
}
}
yIndex++;
}
xIndex++;
}
return pixelTouch;
}
参数other
是对正在进行碰撞测试的另一个TextureRenderable
对象的引用。如果像素在对象之间重叠,那么wcTouchPos
的返回值是 WC 空间中第一个检测到的碰撞位置。请注意,一旦检测到一个像素重叠或当pixelTouch
变为真时,嵌套循环就会终止。这是效率问题的一个重要特征。然而,这也意味着返回的wcTouchPos
只是许多潜在碰撞点中的一个。
支持游戏对象中的逐像素碰撞
编辑game_object.js
文件,将pixelTouches()
函数添加到GameObject
类中:
pixelTouches(otherObj, wcTouchPos) {
// only continue if both objects have getColorArray defined
// if defined, should have other texture intersection support!
let pixelTouch = false;
let myRen = this.getRenderable();
let otherRen = otherObj.getRenderable();
if ((typeof myRen.pixelTouches === "function") &&
(typeof otherRen.pixelTouches === "function")) {
let otherBbox = otherObj.getBBox();
if (otherBbox.intersectsBound(this.getBBox())) {
myRen.setColorArray();
otherRen.setColorArray();
pixelTouch = myRen.pixelTouches(otherRen, wcTouchPos);
}
return pixelTouch;
}
}
该函数检查以确保对象发生碰撞,并将实际的每像素碰撞委托给TextureRenderable
对象。在调用潜在昂贵的TextureRenderable.pixelTouches()
函数之前,注意用于边界框相交检查的intersectsBound()
函数。
在我的游戏中测试每像素碰撞
如图 6-15 所示,每像素碰撞的测试相当简单,包括三个GameObject
实例:大的Collector
小的Portal
小的DyePack
。Collector
和Portal
爪牙分别由箭头键和 WASD 键控制。MyGame
的实现细节与前面的项目类似,不再赘述。
值得注意的代码片段是update()
函数中的冲突测试,如下所示:
update() {
let msg = "No Collision";
this.mCollector.update(engine.input.keys.W, engine.input.keys.S,
engine.input.keys.A, engine.input.keys.D);
this.mPortal.update(engine.input.keys.Up, engine.input.keys.Down,
engine.input.keys.Left, engine.input.keys.Right);
let h = [];
// Portal's resolution is 1/16 x 1/16 that of Collector!
// VERY EXPENSIVE!!
// if (this.mCollector.pixelTouches(this.mPortal, h)) {
if (this.mPortal.pixelTouches(this.mCollector, h)) {
msg = "Collided!: (" + h[0].toPrecision(4) + " " +
h[1].toPrecision(4) + ")";
this.mDyePack.setVisibility(true);
this.mDyePack.getXform().setXPos(h[0]);
this.mDyePack.getXform().setYPos(h[1]);
} else {
this.mDyePack.setVisibility(false);
}
this.mMsg.setText(msg);
}
观察
现在,您可以通过移动两个小东西并使它们在不同位置相交(例如,顶部与底部碰撞,左侧与右侧碰撞)或移动它们以使它们有较大的重叠区域来测试碰撞准确性。注意,预测实际报告的交叉点位置(DyePack
的位置)即使不是不可能,也是相当困难的。重要的是要记住,每像素碰撞函数主要是返回指示是否有碰撞的true
或false
的函数。你不能依靠这个函数来计算实际的碰撞位置。
最后,尝试切换到调用Collector.pixelTouches()
函数来检测冲突。请注意不到实时的性能!在这种情况下,Collector.pixelTouches()
函数的计算成本是Portal.pixelTouches()
函数的 16×16=256 倍。
广义的每像素碰撞
在上一节中,您看到了实现每像素精确碰撞检测所需的基本操作。然而,你可能已经注意到,只有当纹理沿 x/y 轴对齐时,前面的项目才适用。这意味着您的实现不支持旋转对象之间的碰撞。
本节解释当对象旋转时,如何实现每像素精确的碰撞检测。这个项目的基本概念与前一个项目相同;然而,这个版本涉及到向量分解,快速回顾会有所帮助。
向量回顾:组件和分解
回想一下,可以用两个垂直方向将一个矢量分解成相应的分量。例如,图 6-18 包含两个归一化向量,或者分量向量,可以用来分解向量:归一化分量向量
和
将向量
分解成分量
和
。
图 6-18
向量的分解
一般情况下,如图 6-19 所示,给定归一化垂直分量矢量和
以及任意矢量
,以下公式始终成立:
图 6-19
用两个归一化分量向量分解一个向量
由于旋转的图像轴,矢量分解与本项目相关。在没有旋转的情况下,图像可以由沿着默认 x 轴()和 y 轴(
)的熟悉的归一化垂直向量集来参考。你在之前的项目中处理过这个案子。你可以在图 6-20 中看到这样的例子。
图 6-20
轴对齐的纹理
然而,图像旋转后,参考向量集不再沿 x/y 轴。因此,碰撞计算必须考虑新旋转的轴和
,如图 6-21 所示。
图 6-21
旋转纹理及其分量向量
通用像素碰撞项目
这个项目演示了如何以每像素的精度检测两个旋转的TextureRenderable
对象之间的碰撞。与之前的项目类似,在检测到的碰撞位置会显示一个黄色的DyePack
物体(作为测试确认)。你可以在图 6-22 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.5.general_pixel_collisions
文件夹中定义。
图 6-22
运行通用像素碰撞项目
该项目的控制措施如下:
-
箭头键:移动小的纹理物体,即
Portal
小精灵 -
P 键:旋转小的纹理物体,即
Portal
小精灵 -
WASD 键:移动大型纹理物体,
Collector
爪牙 -
E 键:旋转大的纹理物体,即
Collector
小精灵
该项目的目标如下:
-
通过矢量分解访问旋转图像的像素
-
为了支持两个旋转纹理对象之间的每像素精确碰撞检测
您可以在assets
文件夹中找到与上一个项目相同的外部资源文件。
修改像素碰撞以支持旋转
- 编辑
texture_renderable_pixel_collision.js
文件,修改_indexToWCPosition()
功能:
TextureRenderable.prototype._indexToWCPosition =
function (returnWCPos, i, j, xDir, yDir) {
let x = i * this.mXform.getWidth() / this.mElmWidthPixels;
let y = j * this.mXform.getHeight() / this.mElmHeightPixels;
let xDisp = x - (this.mXform.getWidth() * 0.5);
let yDisp = y - (this.mXform.getHeight() * 0.5);
let xDirDisp = [];
let yDirDisp = [];
vec2.scale(xDirDisp, xDir, xDisp);
vec2.scale(yDirDisp, yDir, yDisp);
vec2.add(returnWCPos, this.mXform.getPosition(), xDirDisp);
vec2.add(returnWCPos, returnWCPos, yDirDisp);
}
在列出的代码中,xDir
和yDir
是和
归一化分量向量。变量
xDisp
和yDisp
分别是沿xDir
和yDir
偏移的位移。returnWCPos
的返回值是沿着xDirDisp
和yDirDisp
向量从对象中心位置的简单位移。注意xDirDisp
和yDirDisp
是缩放后的xDir
和yDir
向量。
-
以类似的方式,修改
_wcPositionToIndex()
函数以支持旋转的归一化矢量分量: -
需要修改
pixelTouches()
函数来计算旋转的归一化分量向量:
TextureRenderable.prototype._wcPositionToIndex =
function (returnIndex, wcPos, xDir, yDir) {
// use wcPos to compute the corresponding returnIndex[0 and 1]
let delta = [];
vec2.sub(delta, wcPos, this.mXform.getPosition());
let xDisp = vec2.dot(delta, xDir);
let yDisp = vec2.dot(delta, yDir);
returnIndex[0] = this.mElmWidthPixels *
(xDisp / this.mXform.getWidth());
returnIndex[1] = this.mElmHeightPixels *
(yDisp / this.mXform.getHeight());
// recall that xForm.getPosition() returns center, yet
// Texture origin is at lower-left corner!
returnIndex[0] += this.mElmWidthPixels / 2;
returnIndex[1] += this.mElmHeightPixels / 2;
returnIndex[0] = Math.floor(returnIndex[0]);
returnIndex[1] = Math.floor(returnIndex[1]);
}
TextureRenderable.prototype.pixelTouches = function (other, wcTouchPos) {
let pixelTouch = false;
let xIndex = 0, yIndex;
let otherIndex = [0, 0];
let xDir = [1, 0];
let yDir = [0, 1];
let otherXDir = [1, 0];
let otherYDir = [0, 1];
vec2.rotate(xDir, xDir, this.mXform.getRotationInRad());
vec2.rotate(yDir, yDir, this.mXform.getRotationInRad());
vec2.rotate(otherXDir, otherXDir, other.mXform.getRotationInRad());
vec2.rotate(otherYDir, otherYDir, other.mXform.getRotationInRad());
while ((!pixelTouch) && (xIndex < this.mElmWidthPixels)) {
yIndex = 0;
while ((!pixelTouch) && (yIndex < this.mElmHeightPixels)) {
if (this._pixelAlphaValue(xIndex, yIndex) > 0) {
this._indexToWCPosition(wcTouchPos,
xIndex, yIndex, xDir, yDir);
other._wcPositionToIndex(otherIndex, wcTouchPos,
otherXDir, otherYDir);
if ((otherIndex[0] >= 0) &&
(otherIndex[0] < other.mElmWidthPixels) &&
(otherIndex[1] >= 0) &&
(otherIndex[1] < other.mElmHeightPixels)) {
pixelTouch = other._pixelAlphaValue(
otherIndex[0], otherIndex[1]) > 0;
}
}
yIndex++;
}
xIndex++;
}
return pixelTouch;
}
变量xDir
和yDir
是这个TextureRenderable
物体旋转后的归一化分量向量和
,而
otherXDir
和otherYDir
是碰撞物体的归一化分量向量。这些向量被用作计算从纹理索引到 WC 和从 WC 到纹理索引的变换的参考。
修改游戏对象以支持旋转
回想一下,GameObject
类首先测试两个对象之间的边界框碰撞,然后才真正调用昂贵得多的每像素碰撞计算。如图 6-14 所示,BoundingBox
对象不能正确支持对象旋转,下面的代码弥补了这个缺陷:
pixelTouches(otherObj, wcTouchPos) {
// only continue if both objects have getColorArray defined
// if defined, should have other texture intersection support!
let pixelTouch = false;
let myRen = this.getRenderable();
let otherRen = otherObj.getRenderable();
if ((typeof myRen.pixelTouches === "function") &&
(typeof otherRen.pixelTouches === "function")) {
if ((myRen.getXform().getRotationInRad() === 0) &&
(otherRen.getXform().getRotationInRad() === 0)) {
// no rotation, we can use bbox ...
let otherBbox = otherObj.getBBox();
if (otherBbox.intersectsBound(this.getBBox())) {
myRen.setColorArray();
otherRen.setColorArray();
pixelTouch = myRen.pixelTouches(otherRen, wcTouchPos);
}
} else {
// One or both are rotated, compute an encompassing circle
// by using the hypotenuse as radius
let mySize = myRen.getXform().getSize();
let otherSize = otherRen.getXform().getSize();
let myR = Math.sqrt(0.5*mySize[0]*0.5*mySize[0] +
0.5*mySize[1]*0.5*mySize[1]);
let otherR = Math.sqrt(0.5*otherSize[0]*0.5*otherSize[0] +
0.5*otherSize[1]*0.5*otherSize[1]);
let d = [];
vec2.sub(d, myRen.getXform().getPosition(),
otherRen.getXform().getPosition());
if (vec2.length(d) < (myR + otherR)) {
myRen.setColorArray();
otherRen.setColorArray();
pixelTouch = myRen.pixelTouches(otherRen, wcTouchPos);
}
}
}
return pixelTouch;
}
列出的代码显示,如果旋转了任何一个碰撞对象,那么将使用两个包含的圆来确定对象是否足够接*,以进行昂贵的每像素碰撞计算。这两个圆的半径等于相应TextureRenderable
对象的 x/y 尺寸的斜边。仅当这两个圆之间的距离小于半径之和时,才会调用逐像素碰撞检测。
测试广义的每像素碰撞
测试旋转后的TextureRenderable
对象的代码与上一个项目中的代码基本相同,只是增加了两个旋转控件。没有示出实现的细节。现在可以运行项目,旋转两个对象,并观察精确的碰撞结果。
精灵的逐像素碰撞
之前的项目隐含地假设Renderable
对象被整个纹理贴图覆盖。这种假设意味着逐像素碰撞实现不支持精灵或动画精灵对象。在本节中,您将弥补这一不足。
精灵像素碰撞项目
这个项目演示了如何在屏幕上移动一个动画 sprite 对象,并执行与其他对象的逐像素碰撞检测。该项目测试TextureRenderable
、SpriteRenderable
和SpriteAnimateRenderable
对象碰撞的正确性。你可以在图 6-23 中看到这个项目运行的例子。这个项目的源代码在chapter6/6.6.sprite_pixel_collisions
文件夹中定义。
图 6-23
运行精灵像素碰撞项目
该项目的控制措施如下:
-
箭头和 P 键:移动和旋转
Portal
小工具 -
WASD 键:移动
Hero
-
L、R、H、B 键:选择与
Portal
小精灵碰撞的目标
该项目的目标如下:
- 推广 sprite 和动画 sprite 对象的逐像素碰撞实现
您可以在assets
文件夹中找到以下外部资源文件:包含默认系统字体的fonts
文件夹、minion_sprite.png
和minion_portal.png
。
为 SpriteRenderable 实现逐像素碰撞
编辑sprite_renderable.js
以实现对SpriteRenderable
对象的每像素特定支持:
-
修改
SpriteRenderable
构造函数调用_setTexInfo()
函数初始化逐像素碰撞参数;该功能将在下一步中定义: -
定义
_setTexInfo()
函数来覆盖在TextureRenderable
超类中定义的实例变量。实例变量现在标识当前活动的 sprite 元素,而不是整个纹理图像。
constructor(myTexture) {
super(myTexture);
super._setShader(shaderResources.getSpriteShader());
// sprite coordinate
// bounds of texture coordinate (0 is left, 1 is right)
this.mElmLeft = 0.0;
this.mElmRight = 1.0;
this.mElmTop = 1.0; // 1 is top and 0 is bottom of image
this.mElmBottom = 0.0; //
// sets info to support per-pixel collision
this._setTexInfo();
}
_setTexInfo() {
let imageW = this.mTextureInfo.mWidth;
let imageH = this.mTextureInfo.mHeight;
this.mElmLeftIndex = this.mElmLeft * imageW;
this.mElmBottomIndex = this.mElmBottom * imageH;
this.mElmWidthPixels = ((this.mElmRight - this.mElmLeft)*imageW)+1;
this.mElmHeightPixels = ((this.mElmTop - this.mElmBottom)*imageH)+1;
}
注意,mElmWidthPixel
和mElmHeightPixel
现在包含的像素值对应于 sprite 表中单个 sprite 元素的尺寸,而不是整个纹理贴图的尺寸。
- 当当前 sprite 元素在
setElementUVCoordinate()
和setElementPixelPositions()
函数中更新时,记得调用_setTexInfo()
函数:
setElementUVCoordinate(left, right, bottom, top) {
this.mElmLeft = left;
this.mElmRight = right;
this.mElmBottom = bottom;
this.mElmTop = top;
this._setTexInfo();
}
setElementPixelPositions(left, right, bottom, top) {
// entire image width, height
let imageW = this.mTextureInfo.mWidth;
let imageH = this.mTextureInfo.mHeight;
this.mElmLeft = left / imageW;
this.mElmRight = right / imageW;
this.mElmBottom = bottom / imageH;
this.mElmTop = top / imageH;
this._setTexInfo();
}
支持在 TextureRenderable 中访问 Sprite 像素
编辑texture_renderable_pixel_collision.js
文件,并修改_pixelAlphaValue()
函数以支持使用 sprite 元素索引偏移量的像素访问:
TextureRenderable.prototype._pixelAlphaValue = function (x, y) {
y += this.mElmBottomIndex;
x += this.mElmLeftIndex;
x = x * 4;
y = y * 4;
return this.mColorArray[(y * this.mTextureInfo.mWidth) + x + 3];
}
测试 MyGame 中精灵的每像素碰撞
测试这个项目的代码是对以前项目的简单修改,细节没有列出。请务必注意场景中不同的对象类型。
-
Portal
宠臣:一个简单的TextureRenderable
对象 -
Hero
和Brain
:SpriteRenderable
对象,其中几何体上显示的纹理是在minion_sprite.png
sprite 表中定义的 sprite 元素 -
左右爪牙 :
SpriteAnimateRenderable
在minion_sprite.png
动画精灵表的上两行定义了精灵元素的对象
观察
现在,您可以运行该项目,并观察不同对象类型碰撞的正确结果:
-
试着移动
Hero
物体,观察Brain
物体如何不断寻找并与它碰撞。这就是两个SpriteRenderable
物体碰撞的情况。 -
按下 L/R 键,然后用 WASD 键移动
Portal
小兵,与左右小兵相撞。请记住,您可以使用 P 键旋转Portal
小工具。这就是TextureRenderable
和SpriteAnimatedRenderable
物体碰撞的情况。 -
按下 H 键,然后移动
Portal
小人与Hero
物体碰撞。这就是TextureRenderable
和SpriteRenderable
物体碰撞的情况。 -
按下 B 键,然后移动
Portal
小人与Brain
物体碰撞。这是旋转的TextureRenderable
和SpriteRenderable
物体之间碰撞的情况。
摘要
本章向您展示了如何封装游戏中对象的常见行为,并展示了在客户端的MyGame
测试级别中以更简单、更有组织的控制逻辑的形式进行封装的好处。你复习了 2D 空间中的向量。矢量由其方向和大小来定义。矢量便于描述位移(速度)。您回顾了一些基本的向量运算,包括向量的归一化以及如何计算点积和叉积。您与这些操作符一起实现了面向前方的方向功能,并创建了简单的自主行为,如指向特定对象和追逐。
随着物体的行为越来越复杂,检测物体碰撞的需要就成了一个突出的遗漏。轴对齐边界框,或 AABBs,是作为一种粗略的,但计算有效的解决方案,用于*似物体碰撞。您了解了每像素精确碰撞检测的算法,以及它的精确性是以牺牲性能为代价的。现在,您已经了解了如何通过两种方式降低计算成本。首先,只有当对象彼此足够接*时,例如当它们的边界框碰撞时,才调用像素精确过程。第二,基于较低分辨率的纹理调用像素迭代过程。
当实现像素精确碰撞时,您从处理轴对齐纹理的基本情况开始。实现之后,您返回并添加了对旋转纹理之间的碰撞检测的支持。最后,您将实现一般化以支持 sprite 元素之间的冲突。首先解决最简单的情况,让您测试和观察结果,并帮助定义更高级的问题(在这种情况下,旋转和纹理的子区域)可能需要什么。
在这一章的开始,你的游戏引擎支持有趣的复杂绘图,包括定义 WC 空间,用Camera
对象查看 WC 空间,以及在对象上绘制视觉上令人愉悦的纹理和动画。然而,没有支持对象行为的基础设施。这个缺点导致了客户端实现中初始化和控制逻辑的聚集。通过本章介绍和实现的对象行为抽象、数学和碰撞算法,你的游戏引擎功能现在得到了更好的*衡。游戏引擎的客户端现在有了封装特定行为和检测碰撞的工具。下一章重新检查并增强了Camera
对象的功能。你将学会控制和操纵Camera
物体,并在同一个游戏中处理多个Camera
物体。
游戏设计注意事项
第 1–5 章介绍了在屏幕上绘制、移动和动画显示对象的基础技术。第四章的场景对象项目描述了一个简单的交互行为,并向您展示了如何根据矩形的位置来改变游戏屏幕:回想一下,将矩形移动到左边界会导致级别在视觉上发生变化,而音频支持项目添加了上下文声音来加强整体的现场感。虽然只使用第 1 到第五章中的元素可以构建一个有趣(尽管简单)的益智游戏,但当你可以集成物体检测和碰撞触发时,事情会变得有趣得多;这些行为构成了许多常见游戏机制的基础,并为设计各种有趣的游戏场景提供了机会。
从游戏对象项目开始,你可以看到屏幕元素如何开始协同工作来传达游戏设定;即使这个项目中的互动仅限于角色的移动,场景也开始转变为传达一种场所感的东西。主人公似乎正在一个由许多机械化机器人组成的移动场景中飞行,在屏幕中央有一个小物体,你可能会认为它可能会成为某种特殊的拾取器。
即使在这个开发的基础阶段,头脑风暴游戏机制也是可能的,它有可能成为一个完整游戏的基础。如果你仅仅基于游戏对象项目中的屏幕元素设计一个简单的游戏机制,你会选择什么样的行为,你会要求玩家执行什么样的动作?作为一个例子,想象英雄角色必须避免与飞行机器人相撞,并且也许一些机器人会探测并追逐英雄以试图阻止玩家前进;也许英雄在某种程度上也受到了惩罚,如果他们与机器人接触的话。想象一下,屏幕中央的小物体可以让英雄在一段固定的时间内不可战胜,我们设计的关卡需要暂时不可战胜才能达到目标,因此创建了一个更复杂、更有趣的游戏循环(例如,避免追逐机器人到达电源,激活电源并成为暂时不可战胜,使用不可战胜来达到目标)。有了这些基本的互动,我们就有机会探索许多不同种类的游戏中感觉非常熟悉的机制和关卡设计,所有这些都包含了第六章中涉及的物体探测、追逐和碰撞行为。使用游戏对象项目中显示的元素亲自尝试这个设计练习:你可以设计什么样的简单条件和行为来使你的体验独一无二?你能想到多少种方法来使用屏幕中央的小物体?第十二章的最终设计项目将更详细地探讨这些主题。
这也是一个很好的机会来头脑风暴第一章中讨论的游戏设计的其他九个元素。如果游戏不是以机器人为背景在太空中会怎样?也许背景是在森林里,或者在水下,甚至是完全抽象的东西。你如何加入音频来增强现场感并强化游戏设置?你可能会对你想出的各种各样的设置和场景感到惊讶。将自己限制在第六章涵盖的元素和交互上实际上是一个有益的练习,因为设计约束通常通过塑造和引导你的想法来帮助创作过程。即使是最先进的视频游戏通常也有一套相当基本的核心游戏循环作为基础。
从游戏机制和存在的角度来看, Vectors: Front and Chase 项目都很有趣。当然,许多游戏需要游戏世界中的物体来检测英雄角色,并且会追逐或试图避开玩家(或者两者都有,如果物体有多个状态的话)。该项目还演示了两种不同的追逐行为方法,即时和*滑的追逐,游戏设置通常会影响你选择实施的行为。在即时和*稳追求之间的选择是微妙行为的一个很好的例子,它可以显著地影响存在感。例如,如果你正在设计一个游戏,其中船只在海洋上互动,你可能会希望他们的追逐行为考虑到现实世界的惯性和动量,因为船只不能立即转向并对运动中的变化做出反应;相反,它们*稳而渐进地移动,在对移动目标的反应速度上表现出明显的延迟。物理世界中的大多数对象将在某种程度上显示相同的惯性和动量约束,但也有一些情况下,您可能希望游戏对象直接响应路径变化(或者,您可能希望故意无视现实世界的物理,并创建一个不是基于物理对象限制的行为)。关键是要对你的设计选择有意识,并且要记住几乎没有实现细节小到玩家不会注意到。
边界框和碰撞项目将检测的关键元素引入到你的设计武器库中,允许你开始包含更强大的因果机制,这些机制构成了许多游戏交互的基础。第六章讨论了在精度较低但性能更高的包围盒碰撞检测方法和精度较高但资源密集型的逐像素检测方法之间进行取舍。在许多情况下,边界框方法是足够的,但是如果玩家认为碰撞是任意的,因为边界框与实际的视觉对象相差太大,这会对临场感产生负面影响。当结合每像素碰撞项目的结果时,检测和碰撞甚至是更强大的设计工具。虽然本例中的染料包用于指示第一个碰撞点,但您可以想象围绕两个对象碰撞产生的新对象建立有趣的因果链(例如,玩家追逐对象,玩家与对象碰撞,对象“放弃”新对象,使玩家能够做他们以前不能做的事情)。当然,在游戏屏幕上移动的游戏对象通常是动画,所以精灵像素碰撞项目描述了当对象边界不固定时如何实现碰撞检测。
随着第六章中技术的加入,你现在有了一个临界质量的行为,可以组合起来创建真正有趣的游戏机制,涵盖从动作游戏到谜题。当然,游戏机械行为只是游戏设计的九个元素之一,通常它们本身不足以创造一个神奇的游戏体验:设置、视觉风格、元游戏元素等等都有重要的贡献。好消息是,创造一个令人难忘的游戏体验不需要像你通常认为的那样复杂,伟大的游戏将继续基于第 1—6 章所涵盖的行为和技术的相对基本的组合而产生。最耀眼的游戏并不总是最复杂的,相反,在这些游戏中,九个设计元素的每一个方面都是精心设计的,并且和谐地协同工作。如果你对游戏设计的各个方面都给予了适当的关注和重视,无论你是独自工作还是作为一个大团队的一员,你都有可能创造出伟大的东西。**
七、操纵摄像机
完成本章后,您将能够
-
实现在操作照相机时通常采用的操作
-
在新旧值之间插值以创建*滑过渡
-
理解如何用简单的数学公式描述一些运动或行为
-
使用多个摄像机视图构建游戏
-
将位置从鼠标单击的像素转换到世界坐标(WC)位置
-
在具有多个摄像机的游戏环境中使用鼠标输入的程序
介绍
您的游戏引擎现在能够表示和绘制对象。有了上一章介绍的基本抽象机制,引擎也可以支持这些对象的交互和行为。本章将注意力重新集中在控制和与Camera
对象的交互上,该对象抽象并促进了游戏对象在画布上的呈现。通过这种方式,你的游戏引擎将能够控制和操纵具有良好结构行为的视觉上吸引人的游戏对象的呈现。
图 7-1 简要回顾了在第三章中介绍的Camera
对象抽象。Camera
对象允许游戏程序员定义游戏世界的世界坐标(WC)窗口,显示在 HTML 画布上的视口中。WC 窗口是由 WC 中心和尺寸WWC×HWC定义的边界。viewport 是 HTML 画布上的一个矩形区域,左下角位于( V x , V y ),尺寸为WV×HV。Camera
对象的setViewAndCameraMatrix()
函数封装了细节,并使 WC 窗口边界内所有游戏对象的图形能够显示在相应的视窗中。
图 7-1
查看定义相机对象的 WC 参数
Note
在本书中,WC 窗口或 WC 边界用于指代 WC 窗口边界。
Camera
对象抽象允许游戏程序员忽略 WC 边界和 HTML 画布的细节,专注于设计有趣的游戏体验。在游戏等级中用一个Camera
对象编程应该反映真实世界中物理摄像机的使用。例如,您可能希望*移摄像机以向观众展示环境,您可能希望将摄像机安装在女演员身上并与观众分享她的旅程,或者您可能希望扮演导演的角色并指导场景中的演员保持在摄像机的可视范围内。这些例子的独特特征,比如*移或者跟随角色的视角,是高级功能规范。请注意,在现实世界中,您不需要指定窗口的坐标位置或边界。
本章介绍了一些最常见的相机操作,包括夹紧、*移和缩放。将推导出插值形式的解决方案,以减轻由相机操作导致的恼人或混乱的突然转变。您还将了解如何在同一游戏关卡中支持多个摄像头视图,以及如何使用鼠标输入。
相机操作
在 2D 世界中,您可能希望将对象的移动限制在相机的范围内,*移或移动相机,或者将相机缩放到特定区域或远离特定区域。这些高级别的功能规范可以通过策略性地改变Camera
对象的参数来实现:wc 中心和 WC 窗口的WWC×HWC。关键是为游戏开发人员创建方便的函数,以便在游戏环境中操作这些值。例如,可以为程序员定义缩放功能,而不是增加/减少 WC 窗口的宽度/高度。
相机操作项目
这个项目演示了如何通过使用Camera
对象的 WC 中心、宽度和高度来实现直观的摄像机操作。你可以在图 7-2 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.1.camera_manipulations
文件夹中定义。
图 7-2
运行相机操作项目
该项目的控制措施如下:
-
WASD 键:移动
Dye
角色(Hero
对象)。请注意,当Hero
对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。 -
箭头键:移动
Portal
对象。注意Portal
对象不能移动超过 WC 边界的 80%。 -
L/R/P/H 键:选择
Left
minion、Right
minion、Portal
object 或Hero
object 作为焦点对象;L/R 键还将相机设置在Left
或Right
迷你按钮的中心。 -
N/M 键:放大或缩小相机中心。
-
J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。
该项目的目标如下:
-
体验一些常见的相机操作
-
为了理解从操纵操作到必须改变的相应相机参数值的映射
-
为了实现相机操纵操作
您可以在assets
文件夹中找到以下外部资源:fonts
文件夹,包含默认的系统字体和三个纹理图像(minion_portal.png
、minion_sprite.png
和bg.png
)。第一个纹理图像表示Portal
对象,其余对象为minion_sprite.png
的 sprite 元素,背景为用bg.png
映射的大TextureRenderable
对象纹理。
组织源代码
为了适应功能的增加和Camera
类的复杂性,您将创建一个单独的文件夹来存储相关的源代码文件。类似于将TextureRenderable
复杂的源代码分成多个文件的情况,在这个项目中Camera
类的实现将被分成三个文件。
-
camera_main.js
用于实现以前项目的基本功能 -
camera_manipulation.js
用于支持新引入的操纵操作 -
camera.js
用作班级接入点
实施步骤如下:
-
在
src/engine
中新建一个名为cameras
的文件夹。将camera.js
文件移入该文件夹,并重命名为camera_main.js
。 -
在
src/engine/cameras
中创建一个新文件,命名为camera_manipulation.js
。该文件将用于扩展Camera
类的功能以支持操作。添加以下代码来导入和导出基本的Camera
类功能。目前,这个文件不包含任何有用的源代码,因此没有任何用途。您将在下面的小节中定义适当的扩展函数。 -
通过添加以下代码,创建一个新的
camera.js
作为Camera
访问点:
import Camera from "./camera_main.js";
// new functionality to be defined here in the next subsection
export default Camera;
import Camera from "./camera_manipulation.js";
export default Camera;
有了源代码文件的这种结构,camera_main.js
实现了所有的基本功能,并导出到camera_manipulation.js
,后者为Camera
类定义了附加功能。最后,camera.js
从camera_manipulation.js
导入扩展功能。Camera
类的用户可以简单地从camera.js
导入,并且可以访问所有已定义的功能。这允许camera.js
作为Camera
类的访问点,同时隐藏实现源代码结构的细节。
支持夹紧到摄像机 WC 边界
编辑camera_main.js
到import
边界框功能,并定义一个函数,将与Transform
对象相关的边界固定到摄像机 WC 边界:
import * as glSys from "../core/gl.js";
import BoundingBox from "../bounding_box.js";
import { eBoundCollideStatus } from "../bounding_box.js";
... identical to previous code ...
clampAtBoundary(aXform, zone) {
let status = this.collideWCBound(aXform, zone);
if (status !== eBoundCollideStatus.eInside) {
let pos = aXform.getPosition();
if ((status & eBoundCollideStatus.eCollideTop) !== 0) {
pos[1] = (this.getWCCenter())[1] +
(zone * this.getWCHeight() / 2) –
(aXform.getHeight() / 2);
}
if ((status & eBoundCollideStatus.eCollideBottom) !== 0) {
pos[1] = (this.getWCCenter())[1] –
(zone * this.getWCHeight() / 2) +
(aXform.getHeight() / 2);
}
if ((status & eBoundCollideStatus.eCollideRight) !== 0) {
pos[0] = (this.getWCCenter())[0] +
(zone * this.getWCWidth() / 2) –
(aXform.getWidth() / 2);
}
if ((status & eBoundCollideStatus.eCollideLeft) !== 0) {
pos[0] = (this.getWCCenter())[0] –
(zone * this.getWCWidth() / 2) +
(aXform.getWidth() / 2);
}
}
return status;
}
aXform
对象可以是GameObject
或Renderable
对象的Transform
。clampAtBoundary()
功能通过夹紧aXform
位置确保aXform
的边界保持在摄像机的 WC 边界内。zone
变量定义了 WC 边界的夹紧百分比。例如,1.0 表示钳制到精确的 WC 边界,而 0.9 表示钳制到当前 WC 窗口大小的 90%的边界。值得注意的是,clampAtBoundary()
功能仅在与摄像机 WC 边界冲突的边界上运行。例如,如果aXform
对象的边界完全在摄像机 WC 边界之外,它将保持在外部。
在 camera_manipulation.js 文件中定义相机操纵操作
回想一下,您已经创建了一个空的camera_manipulation.js
源代码文件。现在您已经准备好编辑这个文件,并在Camera
类上定义额外的函数来操作摄像机。
-
编辑
camera_manipulate.js
。确保在Camera
类功能的初始导入和最终导出之间添加代码。 -
导入边界框碰撞状态,并定义
panWidth()
函数根据Transform
对象的边界*移摄像机。该功能是对clampAtBoundary()
功能的补充,它不是改变aXform
位置,而是移动摄像机以确保正确包含aXform
边界。与clampAtBoundary()
功能的情况一样,如果aXform
边界完全在测试的 WC 边界区域之外,则不会改变摄像机。 -
通过添加到
Camera
类原型来定义摄像机*移功能panBy()
和panTo()
。这两个函数通过增加一个增量或者移动它到一个新的位置来改变摄像机的 WC 中心。
import { eBoundCollideStatus } from "../bounding_box.js";
Camera.prototype.panWith = function (aXform, zone) {
let status = this.collideWCBound(aXform, zone);
if (status !== eBoundCollideStatus.eInside) {
let pos = aXform.getPosition();
let newC = this.getWCCenter();
if ((status & eBoundCollideStatus.eCollideTop) !== 0) {
newC[1] = pos[1]+(aXform.getHeight() / 2) –
(zone * this.getWCHeight() / 2);
}
if ((status & eBoundCollideStatus.eCollideBottom) !== 0) {
newC[1] = pos[1] - (aXform.getHeight() / 2) +
(zone * this.getWCHeight() / 2);
}
if ((status & eBoundCollideStatus.eCollideRight) !== 0) {
newC[0] = pos[0] + (aXform.getWidth() / 2) –
(zone * this.getWCWidth() / 2);
}
if ((status & eBoundCollideStatus.eCollideLeft) !== 0) {
newC[0] = pos[0] - (aXform.getWidth() / 2) +
(zone * this.getWCWidth() / 2);
}
}
}
- 定义相对于中心或目标位置缩放摄像机的功能:
Camera.prototype.panBy = function (dx, dy) {
this.mWCCenter[0] += dx;
this.mWCCenter[1] += dy;
}
Camera.prototype.panTo = function (cx, cy) {
this.setWCCenter(cx, cy);
}
Camera.prototype.zoomBy = function (zoom) {
if (zoom > 0) {
this.setWCWidth(this.getWCWidth() * zoom);
}
}
Camera.prototype.zoomTowards = function (pos, zoom) {
let delta = [];
vec2.sub(delta, pos, this.mWCCenter);
vec2.scale(delta, delta, zoom - 1);
vec2.sub(this.mWCCenter, this.mWCCenter, delta);
this.zoomBy(zoom);
}
zoomBy()
功能相对于摄像机的中心进行缩放,而zoomTowards()
功能相对于世界坐标位置进行缩放。如果zoom
变量大于 1,WC 窗口变得更大,你会在我们直观地称为缩小的过程中看到更多的世界。小于 1 的zoom
值放大。图 7-3 显示了zoom=0.5
相对于 WC 中心和Hero
物体位置的缩放结果。
图 7-3
向 WC 中心和目标位置缩放
在我的游戏中操纵摄像机
有两个重要的功能需要测试:*移和缩放。对MyGame
类唯一值得注意的变化是在update()
函数中。init()
、load()
、unload()
和draw()
功能与之前的项目类似,可以在项目源代码中找到。
update() {
let zoomDelta = 0.05;
let msg = "L/R: Left or Right Minion; H: Dye; P: Portal]: ";
// ... code to update each object not shown
// Brain chasing the hero
let h = [];
if (!this.mHero.pixelTouches(this.mBrain, h)) {
this.mBrain.rotateObjPointTo(
this.mHero.getXform().getPosition(), 0.01);
engine.GameObject.prototype.update.call(this.mBrain);
}
// Pan camera to object
if (engine.input.isKeyClicked(engine.input.keys.L)) {
this.mFocusObj = this.mLMinion;
this.mChoice = 'L';
this.mCamera.panTo(this.mLMinion.getXform().getXPos(),
this.mLMinion.getXform().getYPos());
}
if (engine.input.isKeyClicked(engine.input.keys.R)) {
this.mFocusObj = this.mRMinion;
this.mChoice = 'R';
this.mCamera.panTo(this.mRMinion.getXform().getXPos(),
this.mRMinion.getXform().getYPos());
}
if (engine.input.isKeyClicked(engine.input.keys.P)) {
this.mFocusObj = this.mPortal;
this.mChoice = 'P';
}
if (engine.input.isKeyClicked(engine.input.keys.H)) {
this.mFocusObj = this.mHero;
this.mChoice = 'H';
}
// zoom
if (engine.input.isKeyClicked(engine.input.keys.N)) {
this.mCamera.zoomBy(1 - zoomDelta);
}
if (engine.input.isKeyClicked(engine.input.keys.M)) {
this.mCamera.zoomBy(1 + zoomDelta);
}
if (engine.input.isKeyClicked(engine.input.keys.J)) {
this.mCamera.zoomTowards(
this.mFocusObj.getXform().getPosition(),
1 - zoomDelta);
}
if (engine.input.isKeyClicked(engine.input.keys.K)) {
this.mCamera.zoomTowards(
this.mFocusObj.getXform().getPosition(),
1 + zoomDelta);
}
// interaction with the WC bound
this.mCamera.clampAtBoundary(this.mBrain.getXform(), 0.9);
this.mCamera.clampAtBoundary(this.mPortal.getXform(), 0.8);
this.mCamera.panWith(this.mHero.getXform(), 0.9);
this.mMsg.setText(msg + this.mChoice);
}
在列出的代码中,前四个if
语句选择焦点对准的对象,其中 L 和 R 键还通过调用具有适当 WC 位置的panTo()
函数来重新定位相机。第二组四个if
语句控制zoom
,要么朝向 WC 中心,要么朝向当前聚焦对象。然后,该功能将Brain
和Portal
对象分别限制在 WC 边界的 90%和 80%以内。基于Hero
对象的变换(或位置)*移摄像机,该功能最终结束。
现在,您可以运行项目并使用 WASD 键移动Hero
对象。向厕所边界移动Hero
对象,观察被推动的摄像机。用Hero
物体继续推动相机;请注意,由于clampAtBoundary()
函数的调用,Portal
对象将依次被推动,使其永远不会离开摄像机的 WC 边界。现在按下 L/R 键,观察相机中心切换到Left
或Right
迷你按钮的中心。N/M 键演示了相对于中心的直接缩放。要体验相对于目标的缩放,将Hero
对象移向画布的左上方,然后按 H 键选择它作为zoom
焦点。现在,鼠标指针指向英雄对象的头部,可以先按 K 键缩小,然后按 J 键放大。请注意,当您zoom
时,场景中的所有对象都会改变位置,除了Hero
对象周围的区域。对于有许多应用的游戏开发者来说,放大到世界的一个期望区域是一个有用的特性。您可以体验在放大/缩小时移动Hero
对象。
插入文字
现在可以根据高级功能(如*移或缩放)来操纵摄像机。然而,结果通常是渲染图像的突然或视觉上不连贯的变化,这可能导致烦恼或混乱。例如,在之前的项目中,L 或 R 键通过简单分配新的 WC 中心值来使摄像机重新居中。摄像机位置的突然改变导致一个看似新的游戏世界的突然出现。这不仅会在视觉上分散注意力,还会让玩家搞不清发生了什么。
当摄像机参数的新值可用时,不是分配它们并导致突然的变化,而是希望随着时间的推移将值从旧值逐渐变形为新值,或者对值进行插值。例如,如图 7-4 所示,在时间t1 处,一个具有旧值的参数将被赋予一个新值。在这种情况下,插值不是突然更新值,而是随着时间的推移逐渐改变值。它将计算具有递减值的中间结果,并在稍后完成对新值的更改t2。
图 7-4
基于线性和指数函数的插值
图 7-4 显示了随着时间推移有多种插值方式。例如,线性插值根据新旧值连线的斜率计算中间结果。相反,指数函数可以根据以前值的百分比计算中间结果。这样,利用线性插值,摄像机位置将以恒定速度从旧位置移动到新位置,类似于以某个恒定速度移动(或摇摄)摄像机。相比之下,基于给定指数函数的插值将首先快速移动相机位置,然后随着时间的推移快速减慢,给人一种移动相机并将相机聚焦在新目标上的感觉。
人类的动作和运动通常遵循指数插值函数。例如,试着把你的头从正面转向右边或者移动你的手去拿你桌子上的一个物体。注意,在这两种情况下,你都是以相对较快的速度开始运动,当目的地很*的时候,你的速度明显慢了下来。也就是说,你可能开始时快速转动你的头,然后随着你的视线接*你的右侧而快速减速,很可能你的手开始快速向物体移动,当手快要到达物体时明显减速。在这两个例子中,你的位移遵循指数插值函数,如图 7-4 所示,随着目的地的接*,快速变化之后是快速减速。这是您将在游戏引擎中实现的功能,因为它模仿人类的运动,并且对人类玩家来说似乎很自然。
Note
线性插值通常被称为 LERP 或 lerp 。lerp 的结果是初始值和最终值的线性组合。在本章中,几乎在所有情况下,图 7-4 中描绘的指数插值都是通过重复应用 lerp 函数来*似的,其中在每次调用中,初始值都是前一次 lerp 调用的结果。这样,指数函数就用分段线性函数来*似了。
本节介绍了Lerp
和LerpVec2
实用程序类,以支持相机操纵操作产生的*滑和渐进的相机移动。
相机插值项目
这个项目展示了更*滑和视觉上更吸引人的相机操作插值结果。你可以在图 7-5 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.2.camera_interpolations
文件夹中定义。
图 7-5
运行相机插值项目
该项目的控件与之前的项目相同:
-
WASD 键:移动
Dye
角色(Hero
对象)。请注意,当Hero
对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。 -
箭头键:移动
Portal
对象。注意Portal
对象不能移动超过 WC 边界的 80%。 -
L/R/P/H 键:选择
Left
minion、Right
minion、Portal
object 或Hero
object 成为焦点对象。L/R 键还将相机设置为聚焦在Left
或Right
小按钮上。 -
N/M 键:放大或缩小相机中心。
-
J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。
该项目的目标如下:
-
为了理解给定值之间插值的概念
-
为了实现支持相机参数逐渐变化的插值
-
体验相机参数的插值变化
与前面的项目一样,您可以在assets
文件夹中找到外部资源文件。
插值作为一种工具
类似于支持转换功能的Transform
类和支持冲突检测的BoundingBox
类,可以定义一个Lerp
类来支持值的插值。为了保持源代码有条理,应该定义一个新的文件夹来存储这些实用程序。
创建src/engine/utils
文件夹,并将transform.js
和bounding_box.js
文件移动到该文件夹中。
Lerp 类
定义Lerp
类来计算两个值之间的插值:
-
在
src/engine/utils
文件夹中创建一个新文件,命名为lerp.js
,并定义构造函数。该类设计用于在mCycles
的持续时间内从mCurrentValue
到mFinalValue
插值。在每次更新期间,基于mCurrentValue
和mFinalValue
之差的mRate
增量计算中间结果,如下所示。 -
定义计算中间结果的函数:
class Lerp {
constructor(value, cycles, rate) {
this.mCurrentValue = value; // begin value of interpolation
this.mFinalValue = value; // final value of interpolation
this.mCycles = cycles;
this.mRate = rate;
// Number of cycles left for interpolation
this.mCyclesLeft = 0;
}
... implementation to follow ...
}
// subclass should override this function for non-scalar values
_interpolateValue() {
this.mCurrentValue = this.mCurrentValue + this.mRate *
(this.mFinalValue - this.mCurrentValue);
}
注意,_interpolateValue()
函数计算出在mCurrentValue
和mFinalValue
之间线性插值的结果。通过这种方式,mCurrentValue
将在每次迭代逼*指数曲线时被设置为中间值,因为它接*mFinalValue
的值。
-
定义一个函数来配置插值。
mRate
变量定义插值结果接*最终值的速度。0.0 的mRate
将导致完全没有变化,其中 1.0 导致瞬时变化。mCycle
变量定义了插值过程的持续时间。 -
定义相关的 getter 和 setter 函数。注意,
setFinal()
函数既设置最终值,又触发新一轮插值计算。
config(stiffness, duration) {
this.mRate = stiffness;
this.mCycles = duration;
}
- 定义函数来触发每个中间结果的计算:
get() { return this.mCurrentValue; }
setFinal(v) {
this.mFinalValue = v;
this.mCyclesLeft = this.mCycles; // will trigger interpolation
}
- 最后,确保导出已定义的类:
update() {
if (this.mCyclesLeft <= 0) { return; }
this.mCyclesLeft--;
if (this.mCyclesLeft === 0) {
this.mCurrentValue = this.mFinalValue;
} else {
this._interpolateValue();
}
}
export default Lerp
;
LerpVec2 类
由于许多摄像机参数是vec2
对象(例如,厕所中心位置),因此重要的是要泛化Lerp
类以支持vec2
对象的插值:
-
在
src/engine/utils
文件夹中新建一个文件,命名为lerp_vec2.js
,并定义其构造函数: -
覆盖
_interpolateValue()
函数以计算vec2
的中间结果:
class LerpVec2 extends Lerp {
constructor(value, cycle, rate) {
super(value, cycle, rate);
}
... implementation to follow ...
}
_interpolateValue() {
vec2.lerp(this.mCurrentValue, this.mCurrentValue,
this.mFinalValue, this.mRate);
}
在gl-matrix.js
文件中定义的vec2.lerp()
函数计算 x 和 y 的vec2
分量。涉及的计算与Lerp
类中的_interpolateValue()
函数相同。
最后,记得更新引擎访问文件index.js
,以便将新定义的Lerp
和LerpVec2
功能转发给客户端。
用 CameraState 表示插值中间结果
必须将Camera
对象的状态一般化,以支持插值中间结果的渐变。引入CameraState
类就是为了实现这个目的。
- 在
src/engine/cameras
文件夹中新建一个文件,命名为camera_state.js
,导入定义好的Lerp
功能,定义构造函数:
import Lerp from "../utils/lerp.js";
import LerpVec2 from "../utils/lerp_vec2.js";
class CameraState {
constructor(center, width) {
this.kCycles = 300; // cycles to complete the transition
this.kRate = 0.1; // rate of change for each cycle
this.mCenter = new LerpVec2(center, this.kCycles, this.kRate);
this.mWidth = new Lerp(width, this.kCycles, this.kRate);
}
... implementation to follow ...
}
export default CameraState;
注意mCenter
和mWidth
是支持摄像机*移(mCenter
的改变)和变焦(mWidth
的改变)所需的唯一变量。这两个变量都是相应的Lerp
类的实例,能够插值和计算中间结果以实现渐变。
-
定义 getter 和 setter 函数:
-
定义更新函数以触发插值计算:
getCenter() { return this.mCenter.get(); }
getWidth() { return this.mWidth.get(); }
setCenter(c) { this.mCenter.setFinal(c); }
setWidth(w) { this.mWidth.setFinal(w); }
- 定义一个函数来配置插值:
update() {
this.mCenter.update();
this.mWidth.update();
}
config(stiffness, duration) {
this.mCenter.config(stiffness, duration);
this.mWidth.config(stiffness, duration);
}
stiffness
变量是Lerp
的mRate
。它定义了插值中间结果收敛到最终值的速度。正如在Lerp
类定义中所讨论的,这是一个介于 0 和 1 之间的数,其中 0 表示永远不会收敛,1 表示瞬时收敛。duration
变量是Lerp
的mCycle
。它定义了结果收敛所需的更新周期数。这必须是正整数值。
请注意,随着引擎复杂性的增加,支持代码的复杂性也在增加。在这种情况下,您已经设计了一个内部实用程序类CameraState
,用于存储一个Camera
对象的内部状态以支持插值。这是一个内部发动机操作。游戏程序员没有理由访问这个类,因此,引擎访问文件index.js
不应该被修改来转发定义。
将插值集成到相机操作中
必须修改camera_main.js
中的Camera
类,以使用新定义的CameraState
来表示 WC 中心和宽度:
-
编辑
camera_main.js
文件并导入新定义的CameraState
类: -
修改
Camera
构造函数,用CameraState
的实例替换 center 和 width 变量:
import CameraState from "./camera_state.js";
- 现在,编辑
camera_manipulation.js
文件以定义函数来更新和配置CameraState
对象的插值功能:
constructor(wcCenter, wcWidth, viewportArray) {
this.mCameraState = new CameraState(wcCenter, wcWidth);
... identical to previous code ...
}
- 修改
panBy()
相机操作功能,以支持CameraState
对象,如下所示:
Camera.prototype.update = function () {
this.mCameraState.update();
}
// For LERP function configuration
Camera.prototype.configLerp = function (stiffness, duration) {
this.mCameraState.config(stiffness, duration);
}
- 更新
panWith()
和zoomTowards()
函数接收并设置 WC 中心到新定义的CameraState
对象:
Camera.prototype.panBy = function (dx, dy) {
let newC = vec2.clone(this.getWCCenter());
newC[0] += dx;
newC[1] += dy;
this.mCameraState.setCenter(newC);
}
Camera.prototype.panWith = function (aXform, zone) {
let status = this.collideWCBound(aXform, zone);
if (status !== eBoundCollideStatus.eInside) {
let pos = aXform.getPosition();
let newC = vec2.clone(this.getWCCenter());
if ((status & eBoundCollideStatus.eCollideTop) !== 0)
... identical to previous code ...
this.mCameraState.setCenter(newC);
}
}
Camera.prototype.zoomTowards = function (pos, zoom) {
... identical to previous code ...
this.zoomBy(zoom);
this.mCameraState.setCenter(newC);
}
在 MyGame 中测试插值
回想一下,这个项目的用户控件与上一个项目的用户控件是相同的。唯一的区别是,在这个项目中,你可以期待不同相机设置之间的渐进和*滑的过渡。为了观察正确的插值结果,必须在每次游戏场景更新时调用 camera update()
功能。
update() {
let zoomDelta = 0.05;
let msg = "L/R: Left or Right Minion; H: Dye; P: Portal]: ";
this.mCamera.update(); // for smoother camera movements
... identical to previous code ...
}
更新摄像机以计算插值中间结果的调用是my_game.js
文件中唯一的变化。现在,您可以运行该项目,并试验由相机操纵操作产生的*滑和渐变。请注意,插值结果不会突然更改渲染图像,从而保持了操纵命令前后的空间连续性。您可以尝试更改stiffness
和duration
变量,以更好地了解不同的插值收敛速度。
相机抖动和物体振动效果
在视频游戏中,摇动相机可以方便地表达事件的重要性或强烈程度,例如敌人首领的出现或大型物体之间的碰撞。类似于值的插值,相机抖动运动也可以通过简单的数学公式来建模。
考虑在现实生活中相机抖动是如何发生的。例如,在用摄像机拍摄时,说你被某人或某物撞到你而感到惊讶或震惊。你的反应可能是轻微的迷失方向,然后迅速重新聚焦于原来的目标。从相机的角度来看,这种反应可以描述为从原始相机中心的初始大位移,随后是快速调整以使相机重新居中。数学上,如图 7-6 所示,阻尼简谐运动可以用三角函数的阻尼来表示,可以用来描述这些类型的位移。
注意,直接的数学公式是精确的,具有完美的可预测性。这种公式适用于描述规则的、正常的或预期的行为,例如球的弹跳或钟摆的摆动。抖动效果应该包含轻微的混乱和不可预测的随机性,例如,在意外碰撞后端着咖啡的手的稳定,或者像前面的例子一样,在受到惊吓后摄像机的稳定。按照这个推理,在本节中,您将定义一个通用的阻尼振荡函数,然后注入伪随机性来模拟轻微的混沌,以实现抖动效果。
图 7-6
阻尼简谐运动的位移
相机抖动和物体振荡投影
这个项目演示了如何实现阻尼简谐运动来模拟物体振荡,以及如何注入伪随机性来创建相机抖动效果。你可以在图 7-7 中看到这个项目运行的例子。这个项目与上一个项目相同,除了一个创建物体摆动和相机抖动效果的附加命令。这个项目的源代码在chapter7/7.3.camera_shake_and_object_oscillate
文件夹中定义。
图 7-7
运行相机抖动和对象振荡项目
以下是该项目的新控件:
- Q 键:启动染料角色的位置摆动和相机抖动效果。
以下控件与之前的项目相同:
-
WASD 键:移动
Dye
角色(Hero
对象)。请注意,当Hero
对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。 -
箭头键:移动
Portal
对象。注意Portal
对象不能移动超过 WC 边界的 80%。 -
L/R/P/H 键:选择
Left
minion、Right
minion、Portal
object 或Hero
object 成为焦点对象。L/R 键还将相机设置为聚焦在Left
或Right
小按钮上。 -
N/M 键:放大或缩小相机中心。
-
J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。
该项目的目标如下:
-
为了深入了解用简单的数学函数模拟位移
-
要体验对象的振荡效果
-
体验相机的抖动效果
-
将振荡实现为阻尼简谐运动,并引入伪随机性以产生相机抖动效果
与前面的项目一样,您可以在assets
文件夹中找到外部资源文件。
抽象摇动行为
在许多游戏中,摇动相机是一种常见的动态行为。但是,重要的是要认识到抖动行为不仅可以应用于摄像机。也就是说,抖动效果可以被抽象为诸如尺寸、点或位置的数值的扰动(抖动)。在相机抖动的情况下,恰好被抖动的数值代表相机的 x 和 y 位置。由于这个原因,抖动和相关的支持应该是游戏引擎的一般效用函数,以便游戏开发者可以将它们应用于任何数值。以下是将要定义的新实用程序:
-
Oscillate
:实现一个值随时间的简谐振荡的基类 -
Shake
:对Oscillate
类的一个扩展,它将随机性引入到振荡的幅度中,以模拟对一个值的抖动效果的轻微混乱 -
ShakeVec2
:对Shake
类的扩展,将Shake
行为扩展为两个值,比如一个位置
创建振荡类来模拟简谐运动
因为所有描述的行为都依赖于简单振荡,所以应该首先实现这一点;
- 在
src/engine/utils
文件夹中创建一个新文件,并将其命名为oscillate.js
。定义一个名为Oscillate
的类,并添加以下代码来构建该对象:
class Oscillate {
constructor(delta, frequency, duration) {
this.mMag = delta;
this.mCycles = duration; // cycles to complete the transition
this.mOmega = frequency * 2 * Math.PI; // Converts to radians
this.mNumCyclesLeft = duration;
}
... implementation to follow ...
}
export default Oscillate;
delta
变量代表 WC 空间中阻尼前的初始位移。frequency
参数指定值为 1 代表余弦函数的一个完整周期时的振荡幅度。duration
参数定义了以游戏循环更新为单位振荡多长时间。
- 定义阻尼简谐运动:
_nextDampedHarmonic() {
// computes (Cycles) * cos(Omega * t)
let frac = this.mNumCyclesLeft / this.mCycles;
return frac * frac * Math.cos((1 - frac) * this.mOmega);
}
参见图 7-8 。mNumCyclesLeft
是振荡中剩余的周期数,即k-t
,frac
变量是阻尼因子。该函数返回一个介于-1 和 1 之间的值,可以根据需要进行缩放。
图 7-8
指定值振荡的阻尼简谐振动
-
定义一个受保护的函数来检索下一个阻尼谐波运动的值。这个功能可能看起来微不足道,没有必要。然而,正如您将在下一小节中看到的,这个函数允许 shake 子类覆盖和注入随机性。
-
定义检查振荡结束和重启振荡的功能:
// local/protected methods
_nextValue() {
return (this._nextDampedHarmonic());
}
- 最后,定义一个公共函数来触发振荡的计算。请注意,计算的振荡结果必须按所需幅度
mMag
进行缩放:
done() { return (this.mNumCyclesLeft <= 0); }
reStart() { this.mNumCyclesLeft = this.mCycles; }
getNext() {
this.mNumCyclesLeft--;
let v = 0;
if (!this.done()) {
v = this._nextValue();
}
return (v * this.mMag);
}
创建 Shake 类来随机化振荡
现在,您可以通过在效果中引入伪随机性来扩展振荡行为,以传达震动感。
-
在
src/engine/utils
文件夹中创建一个新文件shake.js
。定义Shake
类来扩展Oscillate
,并添加以下代码来构造对象: -
覆盖
_nextValue()
以随机化振荡结果的符号,如下所示。回想一下,从公共的getNext()
函数调用_nextValue()
函数来检索振荡值。虽然来自阻尼简谐振动的结果在幅度上连续地和可预测地减小,但是值的相关符号被随机化,导致突然的和意外的不连续,传达了来自摇动结果的混乱感。
import Oscillate from "./oscillate.js";
class Shake extends Oscillate {
constructor(delta, frequency, duration) {
super(delta, frequency, duration);
}
... implementation to follow ...
}
export default Shake;
_nextValue() {
let v = this._nextDampedHarmonic();
let fx = (Math.random() > 0.5) ? -v : v;
return fx;
}
创建 ShakeVec2 类来模拟 Vec2 或位置的晃动
现在,您可以推广 shake 效果,以同时支持两个值的摇动。这是一个有用的工具,因为 2D 游戏中的位置是两个值的实体,位置是震动效果的方便目标。比如这个项目中,相机位置的抖动,一个二值实体,模拟相机抖动效果。
ShakeVec2
类扩展了Shake
类以支持vec2
对象的摇动,摇动 x 和 y 维度上的值。x 维度的摇动通过Shake
对象的实例来支持,而 y 维度则通过超类中定义的Shake
类功能来支持。
-
在
src/engine/utils
文件夹中创建一个新文件shake_vec2.js
。定义ShakeVec2
类来扩展Shake
类。类似于Shake
超类的构造函数参数,deltas
和freqs
参数是 2D,或vec2
,在 x 和 y 维度上振动的幅度和频率的版本。在构造函数中,xShake
实例变量跟踪 x 维度上的震动效果。注意在super()
构造函数调用中的 y 组件参数,数组索引为 1。Shake
超类跟踪 y 维度上的抖动效果。 -
扩展
reStart()
和getNext()
函数以支持第二维度:
class ShakeVec2 extends Shake {
constructor(deltas, freqs, duration) {
super(deltas[1], freqs[1], duration); // super in y-direction
this.xShake = new Shake(deltas[0], freqs[0], duration);
}
... implementation to follow ...
}
export default ShakeVec2;
reStart() {
super.reStart();
this.xShake.reStart();
}
getNext() {
let x = this.xShake.getNext();
let y = super.getNext();
return [x, y];
}
最后,记得更新引擎访问文件index.js
,以便将新定义的Oscillate
、Shake
和ShakeVec2
功能转发给客户端。
定义 CameraShake 类来抽象相机抖动效果
通过定义的ShakeVec2
类,可以方便地将伪随机阻尼简谐运动的位移应用于Camera
的位置。然而,Camera
对象需要一个额外的抽象层。
-
在
src/engine/cameras
文件夹下创建一个新文件camera_shake.js
,定义接收相机状态、state
参数和震动配置的构造函数:deltas
、freqs
和shakeDuration
。参数state
的数据类型为CameraState
,由摄像机中心位置和宽度组成。 -
定义触发位移计算的函数,以实现摇动效果。请注意,抖动结果是从原始位置偏移的。给定的代码将此偏移添加到原始摄像机中心位置。
import ShakeVec2 from "../utils/shake_vec2.js";
class CameraShake {
// state is the CameraState to be shaken
constructor(state, deltas, freqs, shakeDuration) {
this.mOrgCenter = vec2.clone(state.getCenter());
this.mShakeCenter = vec2.clone(this.mOrgCenter);
this.mShake = new ShakeVec2(deltas, freqs, shakeDuration);
}
... implementation to follow ...
}
export default CameraShake;
- 定义实用函数:查询摇动是否完成,重启摇动,以及 getter/setter 函数。
update() {
let delta = this.mShake.getNext();
vec2.add(this.mShakeCenter, this.mOrgCenter, delta);
}
done() { return this.mShake.done(); }
reShake() {this.mShake.reStart();}
getCenter() { return this.mShakeCenter; }
setRefCenter(c) {
this.mOrgCenter[0] = c[0];
this.mOrgCenter[1] = c[1];
}
与CameraState
类似,CameraShake
也是游戏引擎内部实用程序,不应该导出给客户端游戏程序员。不应更新引擎访问文件index.js
来导出此类。
修改相机以支持抖动效果
通过适当的CameraShake
抽象,支持相机的抖动仅仅意味着启动和更新抖动效果:
-
修改
camera_main.js
和camera_manipulation.js
导入camera_shake.js
,如图所示: -
在
camera_main.js
中,修改Camera
构造函数初始化一个CameraShake
对象:
import CameraShake from "./camera_shake.js";
- 修改
setViewAndCameraMatrix()
函数的步骤 B,使用CameraShake
对象的中心(如果已定义):
constructor(wcCenter, wcWidth, viewportArray) {
this.mCameraState = new CameraState(wcCenter, wcWidth);
this.mCameraShake = null;
... identical to previous code ...
}
- 修改
camera_manipulation.js
文件,添加对启动和重启摇动效果的支持:
setViewAndCameraMatrix() {
... identical to previous code ...
// Step B: Compute the Camera Matrix
let center = [];
if (this.mCameraShake !== null) {
center = this.mCameraShake.getCenter();
} else {
center = this.getWCCenter();
}
... identical to previous code ...
}
- 继续使用
camera_manipulation.js
文件,并修改update()
函数以触发相机抖动更新(如果定义了一个的话):
Camera.prototype.shake = function (deltas, freqs, duration) {
this.mCameraShake = new CameraShake(this.mCameraState,
deltas, freqs, duration);
}
// Restart the shake
Camera.prototype.reShake = function () {
let success = (this.mCameraShake !== null);
if (success)
this.mCameraShake.reShake();
return success;
}
Camera.prototype.update = function () {
if (this.mCameraShake !== null) {
if (this.mCameraShake.done()) {
this.mCameraShake = null;
} else {
this.mCameraShake.setRefCenter(this.getWCCenter());
this.mCameraShake.update();
}
}
this.mCameraState.update();
}
在 MyGame 中测试相机抖动和振荡效果
在init()
和update()
功能中只需要对my_game.js
文件稍加修改,就可以支持用 Q 键触发振荡和相机抖动效果;
-
为在
Dye
角色上创建振动或弹跳效果定义一个新的实例变量: -
修改
update()
功能,用 Q 键触发弹跳和相机抖动效果。在下面的代码中,请注意设计良好的抽象的优势。例如,相机抖动效果是不透明的,程序员需要指定的唯一信息是实际的抖动行为,即抖动幅度、频率和持续时间。相比之下,Dye
角色位置的振荡或弹跳效果是通过明确查询和使用mBounce
结果来实现的。
init() {
... identical to previous code ...
// create an Oscillate object to simulate motion
this.mBounce = new engine.Oscillate(2, 6, 120);
// delta, freq, duration
}
update() {
... identical to previous code ...
if (engine.input.isKeyClicked(engine.input.keys.Q)) {
if (!this.mCamera.reShake())
this.mCamera.shake([6, 1], [10, 3], 60);
// also re-start bouncing effect
this.mBounce.reStart();
}
if (!this.mBounce.done()) {
let d = this.mBounce.getNext();
this.mHero.getXform().incXPosBy(d);
}
this.mMsg.setText(msg + this.mChoice);
}
您现在可以运行该项目,体验模拟相机抖动效果的伪随机阻尼简谐运动。还可以观察Dye
人物 x 位置的摆动。请注意,相机中心位置的位移将进行插值,从而产生更*滑的最终抖动效果。你可以在创建mBounce
对象时或者调用mCamera.shake()
函数时尝试改变参数,以试验不同的振荡和摇动配置。回想一下,在这两种情况下,前两个参数控制初始位移和frequency
(余弦周期数),第三个参数是影响应该持续多长时间的duration
。
多个摄像头
视频游戏通常向玩家呈现游戏世界的多个视图,以传达重要或有趣的游戏信息,例如显示小地图来帮助玩家导航世界,或者提供敌人老板的视图来警告玩家将要发生什么。
在您的游戏引擎中,Camera
类根据绘图的源区域和目的区域抽象出游戏世界的图形表示。绘图的源区域是游戏世界的 WC 窗口,目的区域是画布上的视口区域。这种抽象已经用多个Camera
实例有效地封装和支持了多视图思想。游戏中的每一个视图都可以用一个单独的Camera
对象实例来处理,这个对象有不同的 WC 窗口和视口配置。
多摄像机项目
这个项目演示了如何用多个Camera
对象来表示游戏世界中的多个视图。你可以在图 7-9 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.4.multiple_cameras
文件夹中定义。
图 7-9
运行多摄像机项目
该项目的控件与之前的项目相同:
-
Q 键:启动
Dye
角色的位置摆动和相机抖动效果。 -
WASD 键:移动
Dye
角色(Hero
对象)。请注意,当Hero
对象试图移动超过 WC 边界的 90%时,相机 WC 窗口会随之更新。 -
箭头键:移动
Portal
对象。注意Portal
对象不能移动超过 WC 边界的 80%。 -
L/R/P/H 键:选择
Left
minion、Right
minion、Portal
object 或Hero
object 成为焦点对象。L/R 键还将相机设置为聚焦在Left
或Right
小按钮上。 -
N/M 键:放大或缩小相机中心。
-
J/K 键:放大或缩小,同时保证当前焦点物体的相对位置不变。换句话说,当相机缩放时,除了焦点对准的对象之外,所有对象的位置都将改变。
该项目的目标如下:
-
理解将视图呈现到游戏世界的摄像机抽象
-
体验在同一个游戏关卡中使用多台摄像机
-
为了理解插值配置对于具有特定目的的相机的重要性
与前面的项目一样,您可以在assets
文件夹中找到外部资源文件。
改装相机
相机对象将被稍微修改,以允许绘制带有边界的视口。这将允许在画布上轻松区分相机视图。
- 编辑
camera_main.js
并修改Camera
构造函数,允许程序员定义一个bound
数量的像素来包围摄像机的视口:
constructor(wcCenter, wcWidth, viewportArray, bound) {
this.mCameraState = new CameraState(wcCenter, wcWidth);
this.mCameraShake = null;
this.mViewport = []; // [x, y, width, height]
this.mViewportBound = 0;
if (bound !== undefined) {
this.mViewportBound = bound;
}
this.mScissorBound = []; // use for bounds
this.setViewport(viewportArray, this.mViewportBound);
// Camera transform operator
this.mCameraMatrix = mat4.create();
// background color
this.mBGColor = [0.8, 0.8, 0.8, 1]; // RGB and Alpha
}
请参考下面的setViewport()
功能。默认情况下,bound
被假设为零,相机会绘制到整个mViewport
。当非零时,mViewport
周围的bound
个像素将作为背景色,从而允许在画布上轻松区分多个视口。
- 定义
setViewport()
功能:
setViewport(viewportArray, bound) {
if (bound === undefined) {
bound = this.mViewportBound;
}
// [x, y, width, height]
this.mViewport[0] = viewportArray[0] + bound;
this.mViewport[1] = viewportArray[1] + bound;
this.mViewport[2] = viewportArray[2] - (2 * bound);
this.mViewport[3] = viewportArray[3] - (2 * bound);
this.mScissorBound[0] = viewportArray[0];
this.mScissorBound[1] = viewportArray[1];
this.mScissorBound[2] = viewportArray[2];
this.mScissorBound[3] = viewportArray[3];
}
回想一下,当设置相机视口时,调用gl.scissor()
函数来定义要清除的区域,调用gl.viewport()
函数来标识要绘制的目标区域。以前,剪刀和视口边界是相同的,而在这种情况下,请注意实际的mViewport
边界是比mScissorBound
小的bound
个像素。这些设置允许mScissorBound
标识将被清除为背景色的区域,而mViewport
边界定义用于绘制的实际画布区域。这样,视窗周围的bound
数量的像素将保持背景色。
-
定义
getViewport()
函数来返回为该摄像机保留的实际边界。在这种情况下,它是mScissorBound
,而不是可能更小的视口边界。 -
修改
setViewAndCameraMatrix()
函数,用mScissorBound
绑定剪刀边界,而不是视口边界:
getViewport() {
let out = [];
out[0] = this.mScissorBound[0];
out[1] = this.mScissorBound[1];
out[2] = this.mScissorBound[2];
out[3] = this.mScissorBound[3];
return out;
}
setViewAndCameraMatrix() {
let gl = glSys.get();
... identical to previous code ...
// Step A2: set up corresponding scissor area to limit clear area
gl.scissor(this.mScissorBound[0], // x of bottom-left corner
this.mScissorBound[1], // y position of bottom-left corner
this.mScissorBound[2], // width of the area to be drawn
this.mScissorBound[3]);// height of the area to be drawn
... identical to previous code ...
}
在我的游戏中测试多个摄像头
MyGame
关卡必须创建多个摄像头,对其进行适当配置,并独立绘制每个摄像头。为了便于演示,将创建两个新的Camera
对象,一个聚焦于Hero
对象,另一个聚焦于追逐的Brain
对象。和前面的例子一样,MyGame
级别的实现基本上是相同的。在这个例子中,init()
、draw()
和update()
功能的一些部分被修改以处理多个Camera
对象,并且被突出显示。
-
修改
init()
函数来定义三个Camera
对象。mHeroCam
和mBrainCam
都为它们的视窗定义了一个两像素的边界,其中mHeroCam
的边界被定义为灰色(背景色),而mBrainCam
为白色。注意mBrainCam
对象的刚性插值设置通知相机插值在十个周期内收敛到新值。 -
定义一个辅助函数来绘制三台摄像机共有的世界:
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(50, 36), // position of the camera
100, // width of camera
[0, 0, 640, 480] // viewport (orgX, orgY, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
this.mHeroCam = new engine.Camera(
vec2.fromValues(50, 30), // update each cycle to point to hero
20,
[490, 330, 150, 150],
2 // viewport bounds
);
this.mHeroCam.setBackgroundColor([0.5, 0.5, 0.5, 1]);
this.mBrainCam = new engine.Camera(
vec2.fromValues(50, 30), // update each cycle to point to brain
10,
[0, 330, 150, 150],
2 // viewport bounds
);
this.mBrainCam.setBackgroundColor([1, 1, 1, 1]);
this.mBrainCam.configLerp(0.7, 10);
... identical to previous code ...
}
- 修改
MyGame
对象draw()
函数来绘制所有三个摄像机。注意到mMsg
物体只被吸引到主摄像机mCamera
。因此,回声消息将只出现在主摄像机的视窗中。
_drawCamera(camera) {
camera.setViewAndCameraMatrix();
this.mBg.draw(camera);
this.mHero.draw(camera);
this.mBrain.draw(camera);
this.mPortal.draw(camera);
this.mLMinion.draw(camera);
this.mRMinion.draw(camera);
}
- 修改
update()
函数,用相应的对象*移mHeroCam
和mBrainCam
,并连续移动mHeroCam
视口;
draw() {
// Step A: clear the canvas
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray
// Step B: Draw with all three cameras
this._drawCamera(this.mCamera);
this.mMsg.draw(this.mCamera); // only draw status in main camera
this._drawCamera(this.mHeroCam);
this._drawCamera(this.mBrainCam);
}
Note
在游戏过程中,视窗通常不会改变它们的位置。出于测试目的,以下代码在画布中从左到右连续移动mHeroCam
视口。
update() {
let zoomDelta = 0.05;
let msg = "L/R: Left or Right Minion; H: Dye; P: Portal]: ";
this.mCamera.update(); // for smoother camera movements
this.mHeroCam.update();
this.mBrainCam.update();
... identical to previous code ...
// set the hero and brain cams
this.mHeroCam.panTo(this.mHero.getXform().getXPos(),
this.mHero.getXform().getYPos());
this.mBrainCam.panTo(this.mBrain.getXform().getXPos(),
this.mBrain.getXform().getYPos());
// Move the hero cam viewport just to show it is possible
let v = this.mHeroCam.getViewport();
v[0] += 1;
if (v[0] > 500) {
v[0] = 0;
}
this.mHeroCam.setViewport(v);
this.mMsg.setText(msg + this.mChoice);
}
现在,您可以运行项目,并注意 HTML 画布上显示的三个不同的视口。围绕mHeroCam
和mBrainCam
视口的两个像素宽的边界允许对三个视图进行简单的视觉解析。注意到mBrainCam
视口被绘制在mHeroCam
的顶部。这是因为在MyGame.draw()
功能中,mBrainCam
是最后绘制的。最后绘制的对象总是出现在顶部。您可以移动Hero
对象来观察mHeroCam
跟随英雄,并体验*移相机的*滑插值结果。
现在尝试更改mBrainCam.configLerp()
函数的参数以生成更*滑的插值结果,例如将刚度设置为 0.1,持续时间设置为 100 次循环。请注意,似乎摄像机一直在试图捕捉Brain
物体。在这种情况下,相机需要一个硬插值设置,以确保主对象保持在相机视图的中心。为了获得更激烈和有趣的效果,你可以尝试设置mBrainCam
以获得更*滑的插值结果,例如刚度值为 0.01,持续时间为 200 个周期。有了这些值,摄像机永远也追不上Brain
物体,看起来就像在游戏世界里漫无目的地游荡。
通过相机的鼠标输入
鼠标是一种指示输入设备,它报告画布坐标空间中的位置信息。回想一下第三章的讨论,画布坐标空间只是相对于画布左下角沿 x/y 轴的像素偏移的度量。请记住,游戏引擎定义并使用 WC 空间,其中所有对象和度量都在 WC 中指定。为了让游戏引擎使用报告的鼠标位置,这个位置必须从画布坐标空间转换到 WC。
图 7-10 左侧的图显示了一个鼠标位置位于画布上(mouseX, mouseY)
的例子。图 7-10 右侧的图显示,当左下角的视口位于( V x , V y )且尺寸为WV×HV时
-
mousecx = mouex-vx
-
mouseDCY = mouseYVy
这样,(mouseDCX, mouseDCY)
就是从( V x , V y )开始的偏移量,视口的左下角。
图 7-10
鼠标在画布和视窗上的位置
图 7-11 中的左图显示了设备坐标(DC)空间定义了视口内的像素位置,其偏移量是相对于视口的左下角测量的。为此,DC 空间也被称为像素空间。计算出的(mouseDCX, mouseDCY)
位置是 DC 空间中位置的一个例子。图 7-11 中的右图显示,根据这些公式,该位置可以转化为左下角位于(minWCX, minWCY)
且尺寸为WWC×HWC的 WC 空间:
*
图 7-11
视口 DC 空间和厕所空间中的鼠标位置
了解了如何将位置从画布坐标空间转换到 WC 空间后,现在就可以在游戏引擎中实现鼠标输入支持了。
鼠标输入项目
这个项目演示了游戏引擎中的鼠标输入支持。你可以在图 7-12 中看到这个项目运行的例子。这个项目的源代码在chapter7/7.5.mouse_input
文件夹中定义。
图 7-12
运行鼠标输入项目
该项目的新控件如下:
-
在主
Camera
视图中点击鼠标左键:拖动Portal
对象 -
在
HeroCam
视图中点击鼠标中键:拖动Hero
对象 -
在任何视图中点击鼠标右键/中键:隐藏/显示
Portal
对象
以下控件与之前的项目相同:
-
Q 键:启动
Dye
角色的位置摆动和相机抖动效果 -
WASD 键:移动
Dye
角色(Hero
对象)并推动摄像机 WC 边界 -
箭头键:移动
Portal
对象 -
L/R/P/H 键:用 L/R 键选择焦点对准的物体,将相机重新聚焦到
Left
或Right
迷你 -
N/M 和 J/K 键:放大或缩小相机中心或对焦对象
该项目的目标如下:
-
理解画布坐标空间到 WC 空间的转换
-
为了理解区分鼠标事件的视口的重要性
-
实现坐标空间之间的转换
-
支持和体验使用鼠标输入
与前面的项目一样,您可以在assets
文件夹中找到外部资源文件。
修改 index.js 以将画布 ID 传递给输入组件
为了接收鼠标输入信息,input
组件需要访问 HTML 画布。编辑index.js
并修改init()
函数,以便在初始化时将htmlCamvasID
传递给input
组件。
... identical to previous code ...
// general engine utilities
function init(htmlCanvasID) {
glSys.init(htmlCanvasID);
vertexBuffer.init();
input.init(htmlCanvasID);
audio.init();
shaderResources.init();
defaultResources.init();
}
... identical to previous code ...
在 input.js 中实现鼠标支持
与键盘输入类似,您应该通过编辑input.js
为输入模块添加鼠标支持:
-
编辑
input.js
并定义代表三个鼠标按钮的常量: -
定义支持鼠标输入的变量。与键盘输入类似,鼠标按钮状态是三个布尔元素的数组,每个元素代表三个鼠标按钮的状态。
// mouse button enums
const eMouseButton = Object.freeze({
eLeft: 0,
eMiddle: 1,
eRight: 2
});
- 定义鼠标移动事件处理程序:
let mCanvas = null;
let mButtonPreviousState = [];
let mIsButtonPressed = [];
let mIsButtonClicked = [];
let mMousePosX = -1;
let mMousePosY = -1;
function onMouseMove(event) {
let inside = false;
let bBox = mCanvas.getBoundingClientRect();
// In Canvas Space now. Convert via ratio from canvas to client.
let x = Math.round((event.clientX - bBox.left) *
(mCanvas.width / bBox.width));
let y = Math.round((event.clientY - bBox.top) *
(mCanvas.height / bBox.height));
if ((x >= 0) && (x < mCanvas.width) &&
(y >= 0) && (y < mCanvas.height)) {
mMousePosX = x;
mMousePosY = mCanvas.height - 1 - y;
inside = true;
}
return inside;
}
请注意,鼠标事件处理程序将原始像素位置转换到画布坐标空间,首先检查该位置是否在画布的边界内,然后翻转 y 位置,以便相对于左下角测量位移。
-
定义鼠标按钮单击处理程序来记录按钮事件:
-
定义鼠标按钮释放处理程序,以便于检测鼠标按钮单击事件。回想一下第四章中关于键盘输入的讨论,为了检测按钮弹起事件,你应该测试之前被释放并且当前被点击的按钮状态。
mouseUp()
处理程序记录鼠标按钮的释放状态。
function onMouseDown(event) {
if (onMouseMove(event)) {
mIsButtonPressed[event.button] = true;
}
}
- 修改
init()
函数以接收canvasID
参数并初始化鼠标事件处理程序:
function onMouseUp(event) {
onMouseMove(event);
mIsButtonPressed[event.button] = false;
}
- 修改
update()
函数,以类似于键盘的方式处理鼠标按钮的状态变化。请注意鼠标单击条件,即以前没有单击的按钮现在被单击了。
function init(canvasID) {
let i;
// keyboard support
... identical to previous code ...
// Mouse support
for (i = 0; i < 3; i++) {
mButtonPreviousState[i] = false;
mIsButtonPressed[i] = false;
mIsButtonClicked[i] = false;
}
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('mousemove', onMouseMove);
mCanvas = document.getElementById(canvasID);
}
- 定义检索鼠标位置和鼠标按钮状态的函数:
function update() {
let i;
// update keyboard input state
... identical to previous code ...
// update mouse input state
for (i = 0; i < 3; i++) {
mIsButtonClicked[i] = (!mButtonPreviousState[i]) &&
mIsButtonPressed[i];
mButtonPreviousState[i] = mIsButtonPressed[i];
}
}
- 最后,记住导出新定义的功能:
function isButtonPressed(button) { return mIsButtonPressed[button]; }
function isButtonClicked(button) { return mIsButtonClicked[button]; }
function getMousePosX() { return mMousePosX; }
function getMousePosY() { return mMousePosY; }
export {
keys, eMouseButton,
init, cleanUp, update,
// keyboard
isKeyClicked, isKeyPressed,
// mouse
isButtonClicked, isButtonPressed, getMousePosX, getMousePosY
}
修改相机以支持视口到 WC 空间的转换
Camera
类封装了 WC 窗口和视口,因此应该负责转换鼠标位置。回想一下,为了保持可读性,Camera
类的源代码文件是根据功能进行分离的。该类的基本功能在camera_main.js
中定义。camera_manipulate.js
文件从camera_main.js
导入并定义额外的操作功能。最后,camera.js
文件从camera_manipulate.js
导入以包含所有已定义的函数,并导出Camera
类以供外部访问。
对于Camera
类,这种从后续源代码文件导入以定义额外函数的链接将继续,其中camera_input.js
定义输入功能:
-
在
src/engine/cameras
文件夹中创建一个新文件,并将其命名为camera_input.js
。这个文件将通过定义鼠标输入支持函数来扩展Camera
类。导入以下文件:-
camera_manipulation.js
为Camera
类定义的所有函数 -
eViewport
用于访问视窗阵列的常数 -
input
访问鼠标相关功能的模块
-
-
定义函数将鼠标位置从画布坐标空间转换到 DC 空间,如图 7-10 所示:
import Camera from "./camera_manipulation.js";
import { eViewport } from "./camera_main.js";
import * as input from "../input.js";
... implementation to follow ...
export default Camera;
- 定义一个函数来确定给定的鼠标位置是否在摄像机的视口边界内:
Camera.prototype._mouseDCX = function () {
return input.getMousePosX() - this.mViewport[eViewport.eOrgX];
}
Camera.prototype._mouseDCY = function() {
return input.getMousePosY() - this.mViewport[eViewport.eOrgY];
}
- 定义将鼠标位置转换到 WC 空间的函数,如图 7-11 所示:
Camera.prototype.isMouseInViewport = function () {
let dcX = this._mouseDCX();
let dcY = this._mouseDCY();
return ((dcX >= 0) && (dcX < this.mViewport[eViewport.eWidth]) &&
(dcY >= 0) && (dcY < this.mViewport[eViewport.eHeight]));
}
Camera.prototype.mouseWCX = function () {
let minWCX = this.getWCCenter()[0] - this.getWCWidth() / 2;
return minWCX + (this._mouseDCX() *
(this.getWCWidth() / this.mViewport[eViewport.eWidth]));
}
Camera.prototype.mouseWCY = function () {
let minWCY = this.getWCCenter()[1] - this.getWCHeight() / 2;
return minWCY + (this._mouseDCY() *
(this.getWCHeight() / this.mViewport[eViewport.eHeight]));
}
最后,更新Camera
类访问文件以正确导出新定义的输入功能。这是通过编辑camera.js
文件并用camera_input.js
替换从camera_manipulate.js
的导入来完成的:
import Camera from "./camera_input.js";
export default Camera;
在 MyGame 中测试鼠标输入
要测试的主要功能包括检测哪个视图应该接收鼠标输入、对鼠标按钮状态变化做出反应以及将鼠标单击像素位置转换到 WC 空间的能力。和前面的例子一样,my_game.js
的实现和前面的项目很相似。在这种情况下,只有update()
函数包含与新的鼠标输入功能一起工作的值得注意的变化。
update() {
... identical to previous code ...
msg = "";
// testing the mouse input
if (engine.input.isButtonPressed(engine.input.eMouseButton.eLeft)) {
msg += "[L Down]";
if (this.mCamera.isMouseInViewport()) {
this.mPortal.getXform().setXPos(this.mCamera.mouseWCX());
this.mPortal.getXform().setYPos(this.mCamera.mouseWCY());
}
}
if (engine.input.isButtonPressed(engine.input.eMouseButton.eMiddle)){
if (this.mHeroCam.isMouseInViewport()) {
this.mHero.getXform().setXPos(this.mHeroCam.mouseWCX());
this.mHero.getXform().setYPos(this.mHeroCam.mouseWCY());
}
}
if (engine.input.isButtonClicked(engine.input.eMouseButton.eRight)) {
this.mPortal.setVisibility(false);
}
if (engine.input.isButtonClicked(engine.input.eMouseButton.eMiddle)){
this.mPortal.setVisibility(true);
}
msg += " X=" + engine.input.getMousePosX() +
" Y=" + engine.input.getMousePosY();
this.mMsg.setText(msg);
}
当视口环境很重要时,检查camera.isMouseInViewport()
条件,如在主摄像机视图中单击鼠标左键或在mHeroCam
视图中单击鼠标中键。这与点击鼠标右键或中键来设置Portal
对象的可见性形成对比。无论鼠标位置在哪里,这两次鼠标点击都会导致执行。
您现在可以运行项目并验证到 WC 空间的转换的正确性。在主视图中点击并拖动鼠标左键,或在mHeroCam
视图中点击并拖动鼠标中键,观察相应对象随着鼠标位置变化的准确移动。在错误视图中的鼠标左键或中键拖动操作对相应的对象没有影响。例如,在mHeroCam
或mBrainCam
视图中拖动鼠标左键对Portal
对象没有影响。但是,请注意,鼠标右键或鼠标中键点击控制Portal
对象的可见性,与鼠标指针的位置无关。请注意,浏览器会将鼠标右键单击映射到默认的弹出菜单。因此,你应该避免在游戏中点击鼠标右键。
摘要
这一章是关于控制和交互Camera
对象的。您已经了解了最常见的相机操作,包括夹紧、*移和缩放。这些操作在游戏引擎中实现,具有将高级规范映射到实际 WC 窗口边界参数的效用函数。插值的引入缓解了相机操作带来的突然、通常令人讨厌且可能令人困惑的移动。通过实现相机抖动效果,您已经发现一些运动可以通过简单的数学公式来建模。您还体验了有效的Camera
对象抽象在支持多个摄像机视图中的重要性。最后一节指导您完成了将鼠标位置从画布坐标空间转换到 WC 空间的实现。
在第五章中,您了解了如何用视觉上吸引人的图像来表示和绘制一个对象,以及如何控制这个对象的动画。在第六章中,你会读到如何定义一个抽象来封装一个对象的行为,以及检测对象间冲突所需的基本支持。这一章是关于这些对象的“指导”:什么应该是可见的,焦点应该在哪里,要显示多少世界,如何确保焦点之间的*滑过渡,以及如何从鼠标接收输入。有了这些功能,您现在就有了一个全面的游戏引擎框架,可以表示和绘制对象,建模和管理对象的行为,并控制如何、在哪里以及显示什么对象。
接下来的章节将继续在更高级的水*上检查对象的外观和行为,包括在 2D 世界中创建灯光和照明效果,并基于简单的经典力学模拟和集成行为。
游戏设计注意事项
您已经学习了对象交互的基础知识,现在是开始考虑创建您的第一个简单游戏机制并尝试构成良好游戏体验的逻辑条件和规则的好时机。许多设计师自上而下地进行游戏创作(这意味着他们从实现特定类型的想法开始,如实时策略、塔防或角色扮演游戏),这可能是我们在视频游戏等行业中所期望的,在视频游戏行业中,创作者通常会花相当多的时间作为内容消费者,然后转变为内容制作者。游戏工作室经常强化这种自上而下的设计方法,指派新员工在经验丰富的领导下工作,以学习特定工作室工作的任何类型的最佳实践。事实证明,这对于训练能够胜任地重复已知风格的设计师是有效的,但这并不总是培养能够从头开始设计全新系统和机制的全面创作者的最佳途径。
前面提到的可能会让我们问,“是什么让游戏性形成的很好?”从根本上来说,游戏是一种互动的体验,在这种体验中,必须学习并应用规则来达到特定的结果;所有游戏都必须满足这一最低标准,包括卡牌、棋盘、实体、视频和其他游戏类型。更进一步说,一个好的游戏是一种互动的体验,人们喜欢学习和应用规则,以达到他们觉得投入的结果。当然,在这个简短的定义中有相当多的东西要解开,但作为一个一般规则,当规则是可发现的,一致的,有逻辑意义的,并且当结果感觉像是对掌握这些规则的满意奖励时,玩家会更喜欢游戏。这个定义适用于单个游戏机制和整个游戏体验。用一个比喻来说,把游戏设计想象成由字母(交互)组成的单词(机制)组成的句子(层次)最终形成可读的内容(类型)是很有帮助的。大多数新设计师试图在他们知道字母表之前写小说,每个人都玩过这样的游戏,其中的机制和水*充其量感觉像是用糟糕的语法写的句子,最糟糕的感觉像是令人不满意的、随机混杂的不知所云的字母。
在接下来的几章中,你将了解到 2D 游戏引擎中更多的高级特性,包括照明和物理行为的模拟。您还将了解一套设计技术,使您能够交付一个完整且结构良好的游戏关卡,整合这些技术,并有意识地利用第四章中讨论的游戏设计的九个元素,从头开始提供统一的体验。在设计探索的早期阶段,只关注创建和提炼基本的游戏机制和交互模型通常是有帮助的;在这个阶段,尽量避免考虑设定、元游戏、系统设计之类的东西(随着设计的进展,这些会被合并到设计中)。
我们将探索的第一个设计技巧是一个简单的练习,它允许你开始学习游戏设计字母表:一个“逃离房间”的场景,其中有一个简单的机械装置,你必须完成一个任务才能打开一扇门并获得奖励。这个练习将帮助您深入了解如何创建可发现且一致的格式良好的逻辑规则,当任务被划分为基本的交互时,这将更容易完成。你已经在早期的项目中探索了潜在的基于规则的场景的开端:回想一下第四章中的键盘支持项目,它建议你可以让玩家将一个较小的方块完全移动到一个较大方块的边界,以触发某种行为。那种单一的互动(或“游戏字母表的字母”)如何结合起来形成一种有意义的游戏机制(或“单词”)?图 7-13 为上锁的房间拼图搭建舞台。
图 7-13
该图像表示一个分成三个区域的游戏屏幕。左边是一个可玩的区域,有一个英雄人物(标有 P 的圆圈),一个标有锁图标的不可逾越的障碍,右边是一个奖励区
图 7-13 所示的屏幕是探索新机制的有用起点。这个练习的目标是创建一个玩家必须完成的逻辑挑战,以解锁障碍并获得奖励。任务的具体性质可以基于广泛的基础力学:它可能涉及跳跃或射击、解谜、叙事情境等。关键是保持第一次迭代的简单(第一次挑战应该有有限数量的组成部分有助于解决问题)和可发现性(玩家必须能够试验和学习参与规则,以便他们能够有意识地解决挑战)。在以后的迭代中,您将为这个机制增加复杂性和趣味性,并且您将看到基本机制如何发展以支持多种类型的游戏。
图 7-14 为逻辑关系机制搭建了舞台,玩家必须与环境中的物体互动以学习规则。
图 7-14
游戏屏幕上有各种各样的单个对象
仅仅看着图 7-14 并不能立即看出玩家需要做什么来解锁障碍,因此他们必须进行实验以了解游戏世界的运行规则;正是这种实验形成了游戏机制的核心元素,推动玩家在关卡中前进,而基于其规则的可发现性和逻辑一致性,该机制或多或少会令人满意。在这个例子中,想象一下,当玩家在游戏屏幕上四处移动时,他们注意到当英雄人物与一个物体交互时,它总是以高亮的方式“激活”,如图 7-15 所示,有时会导致锁图标的一部分和锁图标周围三分之一的圆环发光。然而,有些形状在激活时不会使锁和环发光,如图 7-16 所示。
图 7-16
激活某些形状(#3)不会导致锁和环发光(#4)
图 7-15
当玩家在游戏屏幕上移动英雄角色时,这些形状会高亮显示(# 1);激活某些形状会使锁的一部分和周围环的三分之一发光(#2)
精明的玩家会很快学会这个谜题的规则。仅从图 7-15 和 7-16 中你能猜出它们可能是什么吗?如果你觉得卡住了,图 7-17 应该提供足够的信息来解决这个难题。
图 7-17
如图 7-15 所示,激活第一个物体(右上角的圆圈)并使锁的顶部和环的前三分之一发光后,正确序列中的第二个物体(#5)使锁的中部和环的前三分之二发光(#6)
你(和玩家)现在应该有所有需要的线索来学习这个机制的规则和解决这个难题。玩家可以与三种形状进行交互,每一行中每种形状只有一个实例;这些形状分别代表锁图标的顶部、中部和底部,如图 7-15 所示,激活圆形会使锁的相应部分发光。然而,图 7-16 并没有使锁的相应部分发光,不同之处在于这种机制的“挂钩”:锁的各个部分必须在正确的相对位置被激活:顶在顶行的顶部,中间在中间行,底部在底部(你也可以选择要求玩家从顶部开始以正确的顺序激活它们,尽管这一要求仅从图 7-15 到 7-17 中看不出)。
恭喜你,你现在已经创建了一个格式良好且逻辑一致(如果简单)的谜题,具备了构建更大、更有野心的关卡所需的所有元素!这种解锁序列是一种没有叙事背景的游戏机制:在设计的这个阶段,游戏屏幕故意没有游戏设置、视觉风格或流派排列,因为我们不想让任何先入为主的预期给我们的探索带来负担。作为一名设计师,在添加更高层次的游戏元素(如叙事和流派)之前,花时间探索最纯粹的游戏机制会让你受益匪浅,你可能会对意想不到的方向感到惊讶,这些简单的机制将带你构建它们。
像这个例子中的简单机制可以被描述为“以正确的顺序完成一个多阶段的任务以达到一个目标”,并且在许多种类的游戏中有特色;例如,任何需要玩家收集一个物体的各个部分并把它们组合成一个清单来完成挑战的游戏,都利用了这种机制。单独的机制也可以与其他机制和游戏功能相结合,形成复合元素,为您的游戏体验增加复杂性和风味。
这一章中的相机练习提供了很好的例子,告诉你如何增加一个机械师的兴趣;例如,简单的相机操作项目演示了一种推进游戏动作的方法。想象一下,在前面的例子中,当一个玩家获得解锁屏障的奖励后,他们将英雄对象移动到屏幕的右侧,并前进到一个新的“房间”或区域。现在想象一下,当关卡开始时,如果相机以固定的速度推进屏幕,游戏将会发生怎样的变化;自动滚动的加入极大地改变了这种机制,因为玩家必须在前进的障碍将玩家推出屏幕之前解决难题并解锁障碍。第一个实例创建了一个悠闲的解谜游戏体验,而后者通过给玩家有限的时间来完成每个屏幕,大大增加了紧张感。在自动滚动实现中,你如何安排游戏屏幕以确保玩家有足够的时间学习规则和解决难题?
多摄像机项目作为一个小地图特别有用,它提供了游戏世界中当前没有显示在游戏屏幕上的地方的信息;在前面的练习中,假设锁定的关卡出现在游戏世界中除了玩家当前屏幕之外的其他地方,并且充当小地图的辅助相机显示整个游戏世界地图的缩小视图。作为游戏设计者,您可能希望让玩家知道他们何时完成了允许他们前进的任务,并提供关于他们下一步需要去哪里的信息,因此在这种情况下,您可以在小地图上闪烁一个信号灯,以引起对刚刚解锁的关卡的注意,并向玩家显示去哪里。在我们“游戏设计就像一种书面语言”的比喻中,添加额外的元素,如相机行为,以增强或扩展一个简单的机制,是开始形成“形容词”的一种方式,这些“形容词”增加了我们从游戏设计字母表中的字母创建的基本名词和动词的兴趣。
游戏设计师的主要挑战通常是创建需要巧妙实验的场景,同时保持逻辑一致性;通过创造需要创造性解决问题的曲折场景来挫败玩家是完全可以的(我们称之为“好的”挫败感),但是通过创造逻辑上不一致的场景来挫败玩家,让玩家觉得他们在挑战中成功只是靠随机运气(“坏的”挫败感),通常被认为是糟糕的设计。回想一下你玩过的导致糟糕挫败感的游戏:它们哪里出错了,设计者可以做些什么来改善体验?
上锁的房间场景是一个有用的设计工具,因为它迫使您构建基本的机制,但您可能会惊讶于此练习可以产生的各种场景。尝试一些不同的方法来解决上锁房间的难题,看看设计过程会把你带到哪里,但要保持简单。现在,保持专注于单步项目,以打开只需要玩家学习一个规则的空间。在下一章中,你将重温这个练习,并开始创建更有挑战性的机制。*
八、实现照明和阴影
完成本章后,您将能够
-
了解简单照明模型的参数
-
定义使用多个光源的基础设施支持
-
理解漫反射和法线贴图的基础
-
了解镜面反射和 Phong 照明模型的基础知识
-
实现 GLSL 着色器来模拟漫反射和镜面反射以及 Phong 照明模型
-
创建和操纵点光源、*行光源和聚光灯
-
使用 WebGL 模具缓冲区模拟阴影
介绍
到目前为止,在游戏引擎中,你已经实现了大部分功能模块,以提供许多类型的 2D 游戏所需的基础。也就是说,你已经开发了引擎组件和工具类,它们被设计成直接支持实际的游戏性。这是一个很好的方法,因为它允许你系统地扩展引擎的能力,以支持更多类型的游戏和玩法。例如,到目前为止,你可以实现各种不同的游戏,包括益智游戏,自上而下的空间射击游戏,甚至简单的*台游戏。
照明模型或照明模型是一种数学公式,它基于场景中表面反射的*似光能来描述场景的颜色和亮度。在这一章中,你将实现一个照明模型,它间接影响你的游戏引擎可以支持的游戏类型和可以达到的视觉逼真度。这是因为来自游戏引擎的照明支持可以不仅仅是简单的美学效果。当创造性地应用时,照明可以增强游戏性或者为你的游戏提供一个戏剧性的场景。例如,您可以有一个场景,用手电筒照亮英雄的黑暗道路,手电筒闪烁以向玩家传达不安或危险的感觉。此外,虽然照明模型基于物理世界中的灯光行为,但在您的游戏实现中,照明模型允许超现实或物理上不可能的设置,例如显示明亮或彩虹色的过饱和光源,甚至是吸收周围可见光能量的负光源。
当实现游戏引擎中常见的照明模型时,您将需要尝试 3D 空间中的概念来正确模拟光线。因此,必须为光源指定第三维或深度,以将光能投射到游戏对象或Renderable
对象上,它们是*面 2D 几何体。一旦考虑了 3D 概念,实现照明模型的任务就变得简单多了,并且您可以应用计算机图形学的知识来适当地照亮场景。
一个简化的 Phong 光照模型将被导出并实现,它是专门为你的游戏引擎的 2D 方面而设计的。然而,照明模型的原理保持不变。如果您需要更多信息或对 Phong 照明模型的进一步深入分析,请参考第一章的推荐参考书。
照明和 GLSL 实施概述
一般来说,照明模型是一个或一组数学方程,描述人类如何观察环境中光与物体材料的相互作用。正如你所想象的,一个基于物理世界的精确的照明模型可能非常复杂,计算量也很大。Phong 照明模型用一个可以有效实现的相对简单的方程捕捉了光/材料相互作用的许多有趣方面。本章中的项目按以下顺序指导您理解 Phong 照明模型的基本元素:
-
环境光:在没有明确光源的情况下查看灯光效果
-
光源:检测单个光源的照明效果
-
多光源:开发游戏引擎基础设施以支持多光源
-
漫反射和法线贴图:模拟粗糙或漫反射表面的光反射
-
镜面光和材质:模拟从发光表面反射并到达相机的光
-
光源类型:根据不同类型的光源引入照明
-
阴影:*似光线被阻挡的结果
总的来说,这一章中的项目为你的游戏增加了视觉上的复杂性。为了正确地渲染和显示照明的结果,必须对每个受影响的像素执行相关的计算。回想一下,GLSL 片段着色器负责计算每个像素的颜色。这样,Phong 照明模型的每个基本元素都可以作为现有或新的 GLSL 片段着色器的附加功能来实现。在本章的所有项目中,你将从使用 GLSL 片段着色器开始。
背景光
环境光,通常称为背景光,允许您在没有明确光源的情况下看到环境中的对象。例如,在黑夜中,即使所有的灯都关了,你也能看到房间里的物体。在现实世界中,来自窗户、门下或背景的光线会为你照亮房间。背景光照明的真实模拟,通常称为间接照明,在算法上是复杂的,并且在计算上是昂贵的。相反,在计算机图形和大多数 2D 游戏中,环境照明是通过给当前场景或世界中的每个对象添加一种恒定的颜色或环境光来实现的。值得注意的是,虽然环境照明可以提供所需的结果,但这只是一个粗略的*似值,并不能模拟真实世界的间接照明。
全球环境项目
这个项目演示了如何通过为绘制每个Renderable
对象定义一个全局环境颜色和一个全局环境亮度来实现场景中的环境照明。你可以在图 8-1 中看到这个项目运行的例子。这个项目的源代码位于chapter8/8.1.global_ambient
文件夹中。
图 8-1
运行全局环境项目
该项目的控制措施如下:
-
鼠标左键:增加全局红色环境
-
鼠标中键:降低全局红色环境
-
左/右箭头键:降低/增加全局环境亮度
该项目的目标如下:
-
体验环境照明的效果
-
为了理解如何在场景中实现简单的全局环境照明
-
用
SimpleShader
/Renderable
对结构重新熟悉自己,以连接 GLSL 着色器和游戏引擎
您可以在assets
文件夹中找到以下外部资源。fonts
文件夹包含默认的系统字体和两个纹理图像:minion_sprite.png
,它定义了英雄和奴才的精灵元素,以及bg.png
,它定义了背景。
修改 GLSL 着色器
为游戏引擎实现新的着色器或着色功能时,一个很好的起点是 GLSL 着色器。GLSL 代码的创建或修改允许您实现实际的功能细节,这反过来又作为扩展引擎的需求。例如,在本项目中,您将首先向所有现有的 GLSL 着色器添加环境照明功能。对这个新增加的功能的支持变成了指导引擎其余部分修改的需求。对于本章中的所有示例,您都将观察到这种实现模式。因此,首先,将全局环境整合到你的simple_fs.glsl
中。
-
通过定义两个新的统一变量
uGlobalAmbientColor
和uGlobalAmbientIntensity
来修改片段着色器simple_fs.glsl
,并在计算每个像素的最终颜色时将这些变量与uPixelColor
相乘: -
类似地,通过添加
uniform
变量uGlobalAmbientColor
和uGlobalAmbientIntensity
来修改纹理片段着色器texture_fs.glsl
。将这两个变量与采样纹理颜色相乘,以创建背景照明效果。
precision mediump float;
// Color of pixel
uniform vec4 uPixelColor;
uniform vec4 uGlobalAmbientColor; // this is shared globally
uniform float uGlobalAmbientIntensity; // this is shared globally
void main(void) {
// for every pixel called sets to the user specified color
gl_FragColor = uPixelColor * uGlobalAmbientIntensity *
uGlobalAmbientColor;
}
uniform sampler2D uSampler;
// Color of pixel
uniform vec4 uPixelColor;
uniform vec4 uGlobalAmbientColor; // this is shared globally
uniform float uGlobalAmbientIntensity; // this is shared globally
varying vec2 vTexCoord;
void main(void) {
// texel color look up based on interpolated UV value in vTexCoord
vec4 c = texture2D(uSampler, vec2(vTexCoord.s, vTexCoord.t));
c = c * uGlobalAmbientIntensity * uGlobalAmbientColor;
... identical to previous code ...
}
定义为全球共享资源
环境照明影响整个场景,因此,相关的变量必须是全局的和共享的。在这种情况下,两个变量,一个颜色(环境颜色)和一个浮点(颜色的强度),对于引擎的其余部分和客户端应该是全局可访问的。defaultResources
模块非常适合这一目的。编辑src/engine/resources/default_resources.js
文件,定义颜色和亮度变量,以及它们对应的 getters 和 setters,记住要导出功能。
import * as font from "./font.js";
import * as map from "../core/resource_map.js";
// Global Ambient color
let mGlobalAmbientColor = [0.3, 0.3, 0.3, 1];
let mGlobalAmbientIntensity = 1;
function getGlobalAmbientIntensity() { return mGlobalAmbientIntensity; }
function setGlobalAmbientIntensity(v) { mGlobalAmbientIntensity = v; }
function getGlobalAmbientColor() { return mGlobalAmbientColor; }
function setGlobalAmbientColor(v) {
mGlobalAmbientColor = vec4.fromValues(v[0], v[1], v[2], v[3]); }
... identical to previous code ...
export {
init, cleanUp,
// default system font name: this is guaranteed to be loaded
getDefaultFontName,
// Global ambient: intensity and color
getGlobalAmbientColor, setGlobalAmbientColor,
getGlobalAmbientIntensity, setGlobalAmbientIntensity
}
修改简单着色器
现在在 GLSL 着色器中实现了全局环境颜色和强度,您需要修改游戏引擎的其余部分来支持新定义的功能。回想一下,simple_fs.glsl
被SimpleShader
类引用,而texture_fs.glsl
被TextureShader
类引用。由于TextureShader
是SimpleShader
的子类,在texture_fs.glsl
中新定义的 GLSL 功能将通过适当的SimpleShader
修改得到支持。
-
修改
src/engine/shaders
文件夹中的simple_shader.js
文件,从defaultResources
模块导入,用于访问全局环境光效果变量: -
在构造函数中定义两个新的实例变量,用于存储 GLSL 着色器中环境颜色和强度变量的引用或位置:
import * as defaultResources from "../resources/default_resources.js";
- 在
SimpleShader
构造函数的步骤 E 中,调用 WebGLgetUniformLocation()
函数来查询并存储环境颜色和强度的统一变量在 GLSL 着色器中的位置:
this.mGlobalAmbientColorRef = null;
this.mGlobalAmbientIntensityRef = null;
- 在
activate()
函数中,从defaultResources
模块中检索全局环境颜色和强度值,并传递给 GLSL 着色器中相应的统一变量。请注意用于设置统一变量的特定于数据类型的 WebGL 函数名。你大概能猜到,uniform4fv
对应的是vec4
,是颜色存储,uniform1f
对应的是浮动,是强度。
// Step E: Gets references to the uniform variables
this.mPixelColorRef = gl.getUniformLocation(
this.mCompiledShader, "uPixelColor");
this.mModelMatrixRef = gl.getUniformLocation(
this.mCompiledShader, "uModelXformMatrix");
this.mCameraMatrixRef = gl.getUniformLocation(
this.mCompiledShader, "uCameraXformMatrix");
this.mGlobalAmbientColorRef = gl.getUniformLocation(
this.mCompiledShader, "uGlobalAmbientColor");
this.mGlobalAmbientIntensityRef = gl.getUniformLocation(
this.mCompiledShader, "uGlobalAmbientIntensity");
activate(pixelColor, trsMatrix, cameraMatrix) {
let gl = glSys.get();
... identical to previous code ...
// load uniforms
gl.uniformMatrix4fv(this.mCameraMatrixRef, false, cameraMatrix);
gl.uniform4fv(this.mGlobalAmbientColorRef,
defaultResources.getGlobalAmbientColor());
gl.uniform1f(this.mGlobalAmbientIntensityRef,
defaultResources.getGlobalAmbientIntensity());
}
测试环境照明
现在可以定义MyGame
类来验证新定义的环境照明效果的正确性。预计到测试中即将到来的复杂性,MyGame
类源代码将被分成多个文件,类似于你在第七章中使用Camera
类的经历。所有实现MyGame
的文件都有一个以my_game
开头的名字,并以文件中定义的相关功能的指示结束。例如,在后面的示例中,my_game_light.js
表示文件实现了光源相关的逻辑。对于本项目,类似于Camera
类命名方案,MyGame
类的基本功能将在my_game_main.js
中实现,访问将通过文件my_game.js
进行。
-
在
src/my_game
中创建MyGame
类访问文件。现在,MyGame
功能应该从基类实现文件my_game_main.js
中导入。有了对MyGame
类的完全访问权,在这个文件中定义网页onload()
函数就很方便了。 -
创建
my_game_main.js
;从引擎访问文件index.js
导入,从Hero
和Minion
导入;并且记得导出MyGame
功能。现在,和前面所有的例子一样,用将实例变量初始化为null
的constructor
将MyGame
定义为engine.Scene
的子类。
import engine from "../engine/index.js";
import MyGame from "./my_game_main.js";
window.onload = function () {
engine.init("GLCanvas");
let myGame = new MyGame();
myGame.start();
}
- 加载和卸载背景和爪牙:
import engine from "../engine/index.js";
// user stuff
import Hero from "./objects/hero.js";
import Minion from "./objects/minion.js";
class MyGame extends engine.Scene {
constructor() {
super();
this.kMinionSprite = "assets/minion_sprite.png";
this.kBg = "assets/bg.png";
// The camera to view the scene
this.mCamera = null;
this.mBg = null;
this.mMsg = null;
// the hero and the support objects
this.mHero = null;
this.mLMinion = null;
this.mRMinion = null;
}
... implementation to follow ...
}
export default MyGame;
- 用相应的值初始化相机和场景对象,以确保启动时的正确场景视图。请注意场景中的简单元素,相机,大背景,a
Hero
,左右Minion
对象,以及状态消息。
load() {
engine.texture.load(this.kMinionSprite);
engine.texture.load(this.kBg);
}
unload() {
engine.texture.unload(this.kMinionSprite);
engine.texture.unload(this.kBg);
}
- 定义
draw()
功能。像往常一样,最后绘制状态消息,这样它就不会被任何其他对象覆盖。
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(50, 37.5), // position of the camera
100, // width of camera
[0, 0, 640, 480] // viewport (orgX, orgY, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
let bgR = new engine.SpriteRenderable(this.kBg);
bgR.setElementPixelPositions(0, 1900, 0, 1000);
bgR.getXform().setSize(190, 100);
bgR.getXform().setPosition(50, 35);
this.mBg = new engine.GameObject(bgR);
this.mHero = new Hero(this.kMinionSprite);
this.mLMinion = new Minion(this.kMinionSprite, 30, 30);
this.mRMinion = new Minion(this.kMinionSprite, 70, 30);
this.mMsg = new engine.FontRenderable("Status Message");
this.mMsg.setColor([1, 1, 1, 1]);
this.mMsg.getXform().setPosition(1, 2);
this.mMsg.setTextHeight(3);
}
- 最后,实现
update()
函数来更新所有对象,并接收对全局环境颜色和强度的控制:
draw() {
// Clear the canvas
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray
// Set up the camera and draw
this.mCamera.setViewAndCameraMatrix();
this.mBg.draw(this.mCamera);
this.mHero.draw(this.mCamera);
this.mLMinion.draw(this.mCamera);
this.mRMinion.draw(this.mCamera);
this.mMsg.draw(this.mCamera); // draw last
}
update() {
let deltaAmbient = 0.01;
let msg = "Current Ambient]: ";
this.mCamera.update(); // ensure proper interpolated movement
this.mLMinion.update(); // ensure sprite animation
this.mRMinion.update();
this.mHero.update(); // allow keyboard control to move
this.mCamera.panWith(this.mHero.getXform(), 0.8);
let v = engine.defaultResources.getGlobalAmbientColor();
if (engine.input.isButtonPressed(engine.input.eMouseButton.eLeft))
v[0] += deltaAmbient;
if (engine.input.isButtonPressed(engine.input.eMouseButton.eMiddle))
v[0] -= deltaAmbient;
if (engine.input.isKeyPressed(engine.input.keys.Left))
engine.defaultResources.setGlobalAmbientIntensity(
engine.defaultResources.getGlobalAmbientIntensity() –
deltaAmbient);
if (engine.input.isKeyPressed(engine.input.keys.Right))
engine.defaultResources.setGlobalAmbientIntensity(
engine.defaultResources.getGlobalAmbientIntensity() +
deltaAmbient);
msg += " Red=" + v[0].toPrecision(3) + " Intensity=" +
engine.defaultResources.getGlobalAmbientIntensity().toPrecision(3);
this.mMsg.setText(msg);
}
观察
您现在可以运行项目并观察结果。请注意,初始场景是黑暗的。这是因为全局环境颜色的 RGB 值都被初始化为 0.3。由于环境颜色乘以从纹理中采样的颜色,因此结果类似于在整个场景中应用暗色。如果 RGB 值设置为 1.0,强度设置为 0.3,可以实现相同的效果,因为这两组值只是简单地相乘。
在移动到下一个项目之前,尝试摆弄环境红色通道和环境强度,以观察它们对场景的影响。通过按右箭头键,您可以增加整个场景的亮度,并使所有对象更加可见。继续此增量,观察当强度达到超过 15.0 的值时,场景中的所有颜色都向白色收敛或开始过饱和。没有适当的背景,过饱和会分散注意力。然而,同样正确的是,在选择性对象上策略性地创建过饱和可以用于指示重要事件,例如触发陷阱。下一节将介绍如何创建和引导光源来照亮选定的对象。
光源
检查你的周围环境,你可以观察到许多类型的光源,例如,你的台灯,来自太阳的光线,或者一个孤立的灯泡。孤立的灯泡可以描述为向所有方向均匀发光的点或点光源。点光源是你开始分析光源的地方。
基本上,点光源照亮指定点周围的区域或半径。在 3D 空间中,这个照明区域只是一个球体,称为照明体积。点光源的照明体积由光源的位置或球体的中心以及光源照明的距离或球体的半径来定义。为了观察光源的效果,物体必须存在并且在照明体积内。
正如本章介绍中提到的,2D 引擎将需要冒险进入第三维度,以正确模拟光能的传播。现在,考虑你的 2D 发动机;到目前为止,你已经实现了一个系统,其中一切都在 2D。另一种方法是解释引擎在 z = 0 的单个*面上定义和呈现所有内容,对象按绘制顺序分层。在此系统中,您将添加 3D 光源。
要观察光源的效果,其照明体积必须与定义对象的 XY *面上的对象重叠。图 8-2 显示了位于 z = 10 的简单点光源与 z = 0 的*面相交产生的照明体积。这种相交会在*面上产生一个被照亮的圆。下一个项目实现了图 8-2 ,在这里你将使用面向对象的方法检查光源,同时坚持灯光如何照亮场景的预期。这可以通过定义一个Light
对象来表示光源来实现。
图 8-2
点光源和相应的 3D 照明体积
GLSL 实现并集成到游戏引擎中
回想一下,引擎通过SimpleShader
/ Renderable
对的相应子类连接到 GLSL 着色器。SimpleShader
及其子类与 GLSL 着色器和Renderable
接口,其子类为程序员提供了操作具有相同着色器类型的几何图形的许多副本的便利。例如,texture_vs.glsl
和texture_fs.glsl
通过TextureShader
对象连接到游戏引擎,而TextureRenderable
对象允许游戏程序员创建和操作由texture_vs
/ fs
着色器着色的几何图形的多个实例。图 8-3 描绘了下一个项目扩展该架构以实现点光源照明。类封装了点光源的属性,包括位置、半径和颜色。该信息通过LightShader
/ LightRenderable
对转发给 GLSL 片段着色器light_fs
,用于计算适当的像素颜色。GLSL 顶点着色器texture_vs
被重用,因为光源照明涉及到在每个顶点处理的相同信息。
图 8-3
灯光阴影/可渲染灯光对和相应的 GLSL 灯光阴影
最后,重要的是要记住,GLSL 片段着色器会为相应几何体覆盖的每个像素调用一次。这意味着您将要创建的 GLSL 片段着色器将在每帧中被调用多次,可能在几十万甚至几百万的范围内。考虑到游戏循环以实时速率启动重绘,或者大约每秒 60 帧重绘,GLSL 碎片着色器每秒将被调用数百万次!实现的效率对于流畅的体验很重要。
简单灯光着色器项目
这个项目演示了如何实现和照明一个简单的点光源。你可以在图 8-4 中看到这个项目运行的例子。这个项目的源代码位于chapter8/8.2.simple_light_shader
文件夹中。
图 8-4
运行简单灯光着色器项目
该项目的控制措施如下:
-
WASD 键:移动屏幕上的英雄角色
-
WASD 键+鼠标左键:在屏幕上移动英雄人物和光源
-
左/右箭头键:减弱/增强光线强度
-
Z/X 键:增加/减少灯的 Z 位置
-
C/V 键:增大/减小光线半径
该项目的目标如下:
-
了解如何模拟点光源的照明效果
-
观察点光源照明
-
实现支持点光源照明的 GLSL 着色器
创建 GLSL 灯光片段着色器
与前一节一样,实现将从 GLSL 着色器开始。没有必要定义一个新的 GLSL 顶点着色器,因为所涉及的每顶点信息和计算与texture_vs
相同。必须定义一个新的 GLSL 片段着色器来计算照亮的圆。
-
在
src/glsl_shaders
文件夹中,创建一个新文件并将其命名为light_fs.glsl
。 -
参考
texture_fs.glsl
并复制所有统一和变化的变量。这是重要的一步,因为light_fs
片段着色器将通过LightShader
类与游戏引擎接口。反过来,LightShader
类将被实现为TextureShader
的子类,这里假设存在这些变量。 -
现在,定义支持点光源的变量:开/关开关、颜色、位置和半径。需要注意的是,位置和半径是以像素为单位的。
precision mediump float;
// The object that fetches data from texture.
// Must be set outside the shader.
uniform sampler2D uSampler;
// Color of pixel
uniform vec4 uPixelColor;
uniform vec4 uGlobalAmbientColor; // this is shared globally
uniform float uGlobalAmbientIntensity;
// "varying" keyword signifies that the texture coordinate will be
// interpolated and thus varies.
varying vec2 vTexCoord;
... implementation to follow ...
- 按如下方式在
main()
功能中实现灯光照明:-
步骤 A,采样纹理颜色并应用环境颜色和强度。
-
步骤 B,进行光源照明。这是通过确定是否需要计算来实现的——测试灯光是否打开以及像素是否不透明。如果两者都是有利的,则将光位置和当前像素之间的距离与光半径进行比较,以确定像素是否在照明体积内。注意
gl_FragCord.xyz
是当前像素位置的 GLSL 定义变量,并且该计算假设像素空间单位。当所有条件都有利时,光的颜色累积到最终结果。 -
最后一步是应用色调,并通过
gl_FragColor
设置最终颜色。
-
// Light information
uniform bool uLightOn;
uniform vec4 uLightColor;
uniform vec3 uLightPosition; // in pixel space!
uniform float uLightRadius; // in pixel space!
void main(void) {
// Step A: sample the texture and apply ambient
vec4 textureMapColor = texture2D(uSampler,
vec2(vTexCoord.s, vTexCoord.t));
vec4 lgtResults = uGlobalAmbientIntensity * uGlobalAmbientColor;
// Step B: decide if the light should illuminate
if (uLightOn && (textureMapColor.a > 0.0)) {
float dist = length(uLightPosition.xyz - gl_FragCoord.xyz);
if (dist <= uLightRadius)
lgtResults += uLightColor;
}
lgtResults *= textureMapColor;
// Step C: tint texture leave transparent area defined by texture
vec3 r = vec3(lgtResults) * (1.0-uPixelColor.a) +
vec3(uPixelColor) * uPixelColor.a;
vec4 result = vec4(r, textureMapColor.a);
gl_FragColor = result;
}
定义轻类
定义了 GLSL light_fs
着色器后,现在可以定义一个类来封装游戏引擎的点光源:
-
在
src/engine
文件夹中新建一个lights
文件夹。在lights
文件夹中,添加一个新文件,命名为lights.js
。 -
编辑
lights.js
创建Light
类,定义constructor
初始化灯光颜色、位置、半径和开/关状态。记得导出类。 -
为实例变量定义 getters 和 setters:
class Light {
constructor() {
this.mColor = vec4.fromValues(0.1, 0.1, 0.1, 1); // light color
this.mPosition = vec3.fromValues(0, 0, 5); // WC light position
this.mRadius = 10; // effective radius in WC
this.mIsOn = true;
}
... implementation to follow ...
}
export default Light;
// simple setters and getters
setColor(c) { this.mColor = vec4.clone(c); }
getColor() { return this.mColor; }
set2DPosition(p) {
this.mPosition = vec3.fromValues(p[0], p[1], this.mPosition[2]); }
setXPos(x) { this.mPosition[0] = x; }
setYPos(y) { this.mPosition[1] = y; }
setZPos(z) { this.mPosition[2] = z; }
getPosition() { return this.mPosition; }
setRadius(r) { this.mRadius = r; }
getRadius() { return this.mRadius; }
setLightTo(isOn) { this.mIsOn = isOn; }
isLightOn() { return this.mIsOn; }
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
定义 LightShader 类
LightShader
类继承了SpriteShader
类,封装了特定于在light_fs
片段着色器中为点光源定义的统一变量的值的通信。这样,LightShader
类可以作为 GLSL 片段着色器的一个方便的接口。
-
在
src/engine/shaders
文件夹中,创建一个新文件并将其命名为light_shader.js
。 -
将
LightShader
类定义为SpriteShader
的子类。在构造函数中,定义必要的变量来支持将与点光源相关的信息发送到light_fs
片段着色器。引擎中的点光源信息存储在mLight
中,而对Camera
的引用对于将所有信息从 WC 转换到像素空间非常重要。构造函数的最后四行查询获取light_fs
中统一变量的引用位置。不要忘记导出类。 -
定义一个简单的 setter 函数,将灯光和摄影机与着色器相关联:
import SpriteShader from "./sprite_shader.js";
import * as glSys from "../core/gl.js";
class LightShader extends SpriteShader {
constructor(vertexShaderPath, fragmentShaderPath) {
// Call super class constructor
super(vertexShaderPath, fragmentShaderPath);
// glsl uniform position references
this.mColorRef = null;
this.mPosRef = null;
this.mRadiusRef = null;
this.mIsOnRef = null;
this.mLight = null; // the light source in the Game Engine
this.mCamera = null; // camera to draw, need for WC to DC xform
//
// create the references to these uniforms in the LightShader
let shader = this.mCompiledShader;
let gl = glSys.get();
this.mColorRef = gl.getUniformLocation(shader, "uLightColor");
this.mPosRef = gl.getUniformLocation(shader, "uLightPosition");
this.mRadiusRef = gl.getUniformLocation(shader, "uLightRadius");
this.mIsOnRef = gl.getUniformLocation(shader, "uLightOn");
}
... implementation to follow ...
}
export default LightShader;
- 覆盖
activate()
函数,添加新的功能,当光线出现时,在mLight
中加载点光源信息。请注意,您仍然调用超类的activate()
函数来将其余的值传递给light_fs
片段着色器的统一变量。
setCameraAndLight(c, l) {
this.mCamera = c;
this.mLight = l;
}
- 实现
_loadToShader()
函数,将点光源的值传递给着色器中的统一变量。回想一下,这种通信是通过在构造函数和统一函数集中创建的引用来执行的。需要注意的是,摄像机提供了新的坐标空间转换功能wcPosToPixel()
和wcSizeToPixel()
。这两个函数确保light_fs
中的相应值在像素空间中,从而可以执行相关计算,例如位置之间的距离。这些功能的实现将很快被检查。
activate(pixelColor, trsMatrix, cameraMatrix) {
// first call the super class' activate
super.activate(pixelColor, trsMatrix, cameraMatrix);
if (this.mLight !== null) {
this._loadToShader();
} else {
glSys.get().uniform1i(this.mIsOnRef, false); // switch off light!
}
}
_loadToShader(aCamera) {
let gl = glSys.get();
gl.uniform1i(this.mIsOnRef, this.mLight.isLightOn());
if (this.mLight.isLightOn()) {
let p = this.mCamera.wcPosToPixel(this.mLight.getPosition());
let r = this.mCamera.wcSizeToPixel(this.mLight.getRadius());
let c = this.mLight.getColor();
gl.uniform4fv(this.mColorRef, c);
gl.uniform3fv(this.mPosRef, vec3.fromValues(p[0], p[1], p[2]));
gl.uniform1f(this.mRadiusRef, r);
}
}
定义 LightRenderable 类
随着LightShader
被定义为 GLSL light_fs
着色器的接口,你现在可以专注于为游戏程序员定义一个新的Renderable
类。重要的是灯光可以照亮所有的Renderable
类型,包括纹理和动画精灵。因此,新类必须封装所有现有的Renderable
功能,并且是SpriteAnimateRenderable
的子类。你可以把这个新类想象成一个可以被一个Light
物体照亮的SpriteAnimateRenderable
。
-
在
src/engine/renderables
文件夹中创建一个新文件,并将其命名为light_renderable.js
。 -
定义
LightRenderable
类来扩展SpriteAnimateRenderable
,设置着色器来引用新的LightShader
,并在构造函数中初始化一个Light
引用。这是照耀和照亮SpriteAnimateRenderable
的光。不要忘记导出类。 -
在调用超类
draw()
函数完成绘图之前,定义一个draw
函数将相机和照明光源传递给LightShader
;
import SpriteAnimateRenderable from "./sprite_animate_renderable.js";
import * as defaultShaders from "../core/shader_resources.js";
class LightRenderable extends SpriteAnimateRenderable {
constructor(myTexture) {
super(myTexture);
super._setShader(defaultShaders.getLightShader());
// here is the light source
this.mLight = null;
}
... implementation to follow ...
}
export default LightRenderable;
- 最后,只需添加支架即可获得和设置灯光:
draw(camera) {
this.mShader.setCameraAndLight(camera, this.mLight);
super.draw(camera);
}
getLight() { return this.mLight; }
addLight(l) { this.mLight = l; }
在继续之前,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
定义默认的 LightShader 实例
如前所述,当你第一次定义TextureShader
(第五章)时,每个着色器类型只需要一个实例,所有着色器总是被相应的Renderable
类型对游戏程序员隐藏。着色器类型的每个实例都是在引擎初始化期间由src/engine/core
文件夹中的shaderResources
模块创建的。
您现在可以修改引擎,以支持初始化、加载和卸载要在引擎范围内共享的LightShader
对象:
-
编辑
src/engine/core
文件夹中的shader_resources.js
,导入LightShader
;为着色器定义 GLSL 源代码、相应变量和访问函数的路径: -
在
createShaders()
函数中创建灯光着色器的新实例:
... identical to previous code ...
import LightShader from "../shaders/light_shader.js";
// Light Shader
let kLightFS = "src/glsl_shaders/light_fs.glsl"; // FragmentShader
let mLightShader = null;
function getLightShader() { return mLightShader; }
- 在
init()
函数中加载灯光着色器 GLSL 源代码:
function createShaders() {
mConstColorShader = new SimpleShader(kSimpleVS, kSimpleFS);
mTextureShader = new TextureShader(kTextureVS, kTextureFS);
mSpriteShader = new SpriteShader(kTextureVS, kTextureFS);
mLineShader = new LineShader(kSimpleVS, kLineFS);
mLightShader = new LightShader(kTextureVS, kLightFS);
}
- 记得在清理期间释放 GLSL 资源并卸载源代码:
function init() {
let loadPromise = new Promise(
async function(resolve) {
await Promise.all([
text.load(kSimpleFS),
text.load(kSimpleVS),
text.load(kTextureFS),
text.load(kTextureVS),
text.load(kLineFS),
text.load(kLightFS)
]);
resolve();
}).then(
function resolve() { createShaders(); }
);
map.pushPromise(loadPromise);
}
- 最后,导出访问函数以允许在引擎中共享创建的实例:
function cleanUp() {
mConstColorShader.cleanUp();
mTextureShader.cleanUp();
mSpriteShader.cleanUp();
mLineShader.cleanUp();
mLightShader.cleanUp();
text.unload(kSimpleVS);
text.unload(kSimpleFS);
text.unload(kTextureVS);
text.unload(kTextureFS);
text.unload(kLineFS);
text.unload(kLightFS);
}
export {init, cleanUp,
getConstColorShader, getTextureShader,
getSpriteShader, getLineShader, getLightShader}
修改相机
在渲染LightShader
对象时,多次调用Camera
实用函数,如wcPosToPixel()
。这些函数计算 WC 和像素空间之间的转换。这种转换需要计算中间值,例如 WC 窗口的左下角,这些值在每次渲染调用期间不会改变。为了避免重复计算这些值,应该为Camera
对象定义一个每次渲染的调用缓存。
为相机定义每渲染缓存
定义每渲染缓存以存储支持着色操作所需的中间值:
-
编辑
camera_main.js
并定义一个PerRenderCache
类;在构造函数中,定义变量来保存 WC 空间和像素空间之间的比率以及Camera
的原点。这些是计算从 WC 到像素空间的变换所需的中间值,并且一旦渲染开始,这些值不会改变。 -
修改
Camera
类来实例化一个新的PerRenderCache
对象。值得注意的是,这个变量代表信息的本地缓存,应该对引擎的其他部分隐藏。
class PerRenderCache {
// Information to be updated once per render for efficiency concerns
constructor() {
this.mWCToPixelRatio = 1; // WC to pixel transformation
this.mCameraOrgX = 1; // Lower-left corner of camera in WC
this.mCameraOrgY = 1;
}
}
- 通过添加一个步长 B3 来初始化
setViewAndCameraMatrix()
函数中的每渲染缓存,以根据Camera
视口宽度、世界宽度和世界高度来计算和设置缓存:
constructor(wcCenter, wcWidth, viewportArray, bound) {
... identical to previous code ...
// per-rendering cached information
// needed for computing transforms for shaders
// updated each time in SetupViewProjection()
this.mRenderCache = new PerRenderCache();
// SHOULD NOT be used except
// xform operations during the rendering
// Client game should not access this!
}
setViewAndCameraMatrix() {
... identical to previous code ...
// Step B2: first operation is to translate camera center to origin
mat4.translate(this.mCameraMatrix, this.mCameraMatrix,
vec3.fromValues(-center[0], -center[1], 0));
// Step B3: compute and cache per-rendering information
this.mRenderCache.mWCToPixelRatio =
this.mViewport[eViewport.eWidth] / this.getWCWidth();
this.mRenderCache.mCameraOrgY = center[1] - (this.getWCHeight() / 2);
this.mRenderCache.mCameraOrgX = center[0] - (this.getWCWidth() / 2);
}
请注意,PerRenderCache
类完全位于camera_main.js
文件的本地。隐藏并小心处理复杂的本地缓存功能非常重要。
添加相机变换功能
现在,每渲染缓存已经定义并正确初始化,您可以扩展相机的功能,以支持从 WC 到像素空间的转换。为了代码的可读性和可维护性,这个功能将在一个单独的文件中实现。另一个重要的注意事项是,由于您正在从 WC 转换到像素空间,而像素空间没有 z 轴,因此您需要为像素空间坐标计算一个假 z 值。
-
编辑
Camera
访问文件camera.js
,以从文件camera_xform.js
导入,该文件将包含最新的附加功能,WC 到像素空间转换支持: -
在
src/engine/cameras
文件夹中,创建一个新文件并将其命名为camera_xform.js
。从camera_input.js
导入,这样您可以继续向Camera
类添加新功能,并且不要忘记导出。
import Camera from "./camera_xform.js";
export default Camera;
- 根据
mWCToPixelRatio
变量,通过缩放输入参数,创建一个*似假像素空间 z 值的函数:
import Camera from "./camera_input.js";
import { eViewport } from "./camera_main.js";
... implementation to follow ...
export default Camera;
- 定义一个函数,通过减去相机原点,然后用
mWCToPixelRatio
缩放,从 WC 转换到像素空间。x 和 y 转换结束时的 0.5 偏移确保您使用的是像素的中心而不是角落。
Camera.prototype.fakeZInPixelSpace = function (z) {
return z * this.mRenderCache.mWCToPixelRatio;
}
- 最后,通过使用
mWCToPixelRatio
变量进行缩放,定义一个将长度从 WC 转换到像素空间的函数:
Camera.prototype.wcPosToPixel = function (p) { // p is a vec3, fake Z
// Convert the position to pixel space
let x = this.mViewport[eViewport.eOrgX] +
((p[0] - this.mRenderCache.mCameraOrgX) *
this.mRenderCache.mWCToPixelRatio) + 0.5;
let y = this.mViewport[eViewport.eOrgY] +
((p[1] - this.mRenderCache.mCameraOrgY) *
this.mRenderCache.mWCToPixelRatio) + 0.5;
let z = this.fakeZInPixelSpace(p[2]);
return vec3.fromValues(x, y, z);
}
Camera.prototype.wcSizeToPixel = function (s) { //
return (s * this.mRenderCache.mWCToPixelRatio) + 0.5;
}
测试光线
必须修改MyGame
级别,以利用和测试新定义的灯光功能。
修改英雄和仆从
修改Hero
和Minion
类以适应新的LightRenderable
对象:
-
编辑
src/my_game/objects
文件夹中的hero.js
文件;在构造函数中,用一个LightRenderable
实例化替换SpriteRenderable
: -
编辑
src/my_game/objects
文件夹中的minion.js
文件;在构造函数中,用一个LightRenderable
实例化替换SpriteRenderable
:
constructor(spriteTexture) {
super(null);
this.kDelta = 0.3;
this.mRenderComponent = new engine.LightRenderable(spriteTexture);
... identical to previous code ...
}
constructor(spriteTexture, atX, atY) {
super(null);
this.kDelta = 0.2;
this.mRenderComponent = new engine.LightRenderable(spriteTexture);
... identical to previous code ...
}
修改 MyGame 对象
随着灯光的实现完成和游戏对象的正确更新,你现在可以修改MyGame
等级来显示和测试光源。由于在为新对象添加变量、初始化对象、绘制对象和更新对象的my_game_main.js
文件中代码变化的简单性和重复性,这里将不显示细节。
观察
现在项目已经完成,您可以运行它并检查结果。有一些观察值得注意。首先,光源的照明效果看起来像一个圆形。如图 8-2 所示,这是你的物体所在的 z = 0 *面上的点光源的照射圆。按 Z 或 X 键增加或减少灯光的 Z 位置,以观察被照亮的圆因相交区域的变化而变小或变大。当您继续增加/减少 z 位置时,可以验证球体/*面相交的结果。当球体离开 z=0 *面的距离超过其半径时,被照亮的圆最终将开始变小,并最终完全消失。
您也可以按 C 或 V 键来增加或减少点光源半径,以增加或减少照明量,并观察照明圆半径的相应变化。
现在,按住 WASD 键和鼠标左键移动Hero
,观察点光源总是跟随Hero
并正确照亮背景。请注意,光源照亮了左侧的仆人、英雄和背景,但没有照亮场景中的其他三个对象。这是因为右边的迷你和红色和绿色块不是LightRenderable
对象,因此不能被定义的光源照亮。
多光源和距离衰减
在上一个项目中,定义了能够照亮球形体积的单点光源。这种类型的光源在许多游戏中是有用的,但是仅限于单个光源是有限制性的。引擎应该支持来自多个光源的照明,以满足不同游戏的设计需求。这个缺点将在下一个项目中得到弥补,对多种光源提供全面支持。多个光源的实现原理与之前的项目相同,只是将单个光源替换为一个光源阵列。如图 8-5 所示,将定义一个新的Light
对象,而LightRenderable
对象将被修改以支持Light
对象的数组。LightShader
对象将定义一个由ShaderLightAtIndex
对象组成的数组,这些对象能够将光源信息传递给 GLSL light_fs
片段着色器中的uLights
数组,以进行照明计算。
图 8-5
支持多种光源
可以改进上一个项目的点光源照明效果。你已经观察到在它的边界处,被照亮的圆突然消失,并有一个明显的亮度过渡。照明结果的这种突然消失并不反映真实生活,在真实生活中,来自给定光源的效果随着距离逐渐减小,而不是突然关闭。视觉上更令人愉悦的灯光照明结果应该显示一个照亮的圆,其中边界处的照明结果逐渐消失。这种光照效果随距离的逐渐减少被称为距离衰减。用二次函数来*似距离衰减是一种常见的做法,因为它们产生的效果类似于真实世界。一般来说,距离衰减可以通过多种方式*似计算,并且通常会根据游戏的需要进行调整。
在下文中,您将实现*截止距离和远截止距离,即距离衰减效果开始和结束的光源的两个距离。这两个值使您可以控制光源,以显示完全照亮的中心区域,照明衰减仅发生在指定的距离。最后,光强度将被定义为允许光变暗而不改变其颜色。有了这些附加参数,就有可能定义显著不同的效果。例如,可以有一个柔和的、几乎不明显的灯光覆盖很大的区域,或者一个过饱和的发光灯光集中在场景中的一小块区域。
多重灯光项目
这个项目演示了如何在一个场景中实现多个点光源。它还演示了如何增加点光源模型的复杂性,以便它们更灵活地服务于更广泛的用途。你可以在图 8-6 中看到这个项目运行的例子。这个项目的源代码位于chapter8/8.3.multiple_lights
文件夹中。
图 8-6
运行多重灯光项目
该项目的控制措施如下:
-
WASD 键:移动屏幕上的英雄角色
-
数字键 0、1、2、3 :选择对应的光源
-
箭头键:移动当前选中的灯
-
Z/X 键:增加/减少灯的 Z 位置
-
C/V 和 B/N 键:增加/减少所选光线的远*截止距离
-
K/L 键:增加/减少所选光线的强度
-
H 键:打开/关闭选择的灯
该项目的目标如下:
-
构建支持引擎和 GLSL 着色器中多个光源的基础结构
-
理解和检查光的距离衰减效应
-
体验控制和操纵场景中的多个光源
修改 GLSL 灯光片段着色器
需要修改light_fs
片段着色器以支持距离衰减、截止和多光源:
- 在
light_fs.glsl
文件中,删除为单个灯光添加的灯光变量,并添加一个struct
用于保存位置、颜色、*距离、远距离、强度和开/关变量的灯光信息。定义好struct
后,给片段着色器添加一个uniform
灯光阵列。请注意,添加了一个#define
来保存要使用的光源数量。
Note
GLSL 要求数组大小和循环迭代次数为常数。kGLSLuLightArraySize
是光阵列大小和相应循环迭代控制的常数。您可以随意更改该值,以定义硬件支持的尽可能多的灯光。例如,您可以尝试将灯的数量增加到 50,然后测试和测量性能。
- 定义
LightEffect()
函数来计算光源的照明结果。该函数使用光源和当前像素之间的距离来确定像素是位于*半径内、*半径和远半径之间,还是比远半径更远。如果像素位置位于*半径内,则没有衰减,因此strength
被设置为 1。如果位置在远*半径之间,那么strength
由二次函数调制。大于远半径的距离将导致相应光源没有照明,或者strength
为 0。
// Light information
#define kGLSLuLightArraySize 4
// GLSL Fragment shader requires loop control
// variable to be a constant number. This number 4
// says, this fragment shader will _ALWAYS_ process
// all 4 light sources.
// ***********WARNING***********************
// This number must correspond to the constant with
// the same name defined in LightShader.js file.
// ***********WARNING**************************
// To change this number MAKE SURE: to update the
// kGLSLuLightArraySize
// defined in LightShader.js file.
struct Light {
vec3 Position; // in pixel space!
vec4 Color;
float Near; // distance in pixel space
float Far; // distance in pixel space
float Intensity;
bool IsOn;
};
uniform Light uLights[kGLSLuLightArraySize];
// Maximum array of lights this shader supports
- 修改 main 函数以遍历所有已定义的光源,并调用
LightEffect()
函数来计算和累加阵列中相应光源的贡献:
vec4 LightEffect(Light lgt) {
vec4 result = vec4(0);
float strength = 0.0;
float dist = length(lgt.Position.xyz - gl_FragCoord.xyz);
if (dist <= lgt.Far) {
if (dist <= lgt.Near)
strength = 1.0; // no attenuation
else {
// simple quadratic drop off
float n = dist - lgt.Near;
float d = lgt.Far - lgt.Near;
strength = smoothstep(0.0, 1.0, 1.0-(n*n)/(d*d));
// blended attenuation
}
}
result = strength * lgt.Intensity * lgt.Color;
return result;
}
void main(void) {
// simple tint based on uPixelColor setting
vec4 textureMapColor = texture2D(uSampler,
vec2(vTexCoord.s, vTexCoord.t));
vec4 lgtResults = uGlobalAmbientIntensity * uGlobalAmbientColor;
// now decide if we should illuminate by the light
if (textureMapColor.a > 0.0) {
for (int i=0; i<kGLSLuLightArraySize; i++) {
if (uLights[i].IsOn) {
lgtResults += LightEffect(uLights[i]);
}
}
}
lgtResults *= textureMapColor;
... identical to previous code ...
}
修改灯光类别
游戏引擎Light
对象必须修改,以反映light_fs
片段着色器中新添加的属性:*和远衰减和强度。
-
修改
Lights.js
构造函数,为新属性定义变量: -
为变量定义相应的 get 和 set 访问器。请注意,半径变量已被一般化,并被远*截止距离所取代。
constructor() {
this.mColor = vec4.fromValues(0.1, 0.1, 0.1, 1); // light color
this.mPosition = vec3.fromValues(0, 0, 5); // light position in WC
this.mNear = 5; // effective radius in WC
this.mFar = 10; // within near is full on, outside far is off
this.mIntensity = 1;
this.mIsOn = true;
}
setNear(n) { this.mNear = n; }
getNear() { return this.mNear; }
setFar(f) { this.mFar = f; }
getFar() { return this.mFar; }
setIntensity(i) { this.mIntensity = i; }
getIntensity() { return this.mIntensity; }
setLightTo(on) { this.mIsOn = on; }
定义最轻等级
您将定义一个LightSet
类来帮助处理一组Light
对象。在src/engine/lights
文件夹中,创建一个新文件,命名为light_set.js
。定义使用一组Light
对象的基本接口。
class LightSet {
constructor() { this.mSet = []; }
numLights() { return this.mSet.length; }
getLightAt(index) { return this.mSet[index]; }
addToSet(light) { this.mSet.push(light); }
}
export default LightSet;
最后,不要忘记导出该类,并记住更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
定义 ShaderLightAt 类
定义ShaderLightAt
类,将信息从Light
对象发送到light_fs
GLSL 片段着色器中uLights
数组中的元素:
-
在
src/engine/shaders
文件夹中,新建一个文件,命名为shader_light_at.js
;定义ShaderLightAt
类和构造函数来接收着色器和uLight
数组的索引。不要忘记导出类。 -
实现
_setShaderReferences()
函数,将灯光属性引用设置为light_fs
片段着色器中uLights
数组中的特定索引:
import * as glSys from "../core/gl.js";
class ShaderLightAt {
constructor(shader, index) {
this._setShaderReferences(shader, index);
}
... implementation to follow ...
}
export default ShaderLightAt;
- 实现
loadToShader()
函数将灯光属性推送到light_fs
片段着色器。注意,这个函数类似于前一个项目的light_shader.js
文件中定义的_loadToShader()
函数。重要的区别在于,在这种情况下,光线信息被加载到特定的数组索引中。
_setShaderReferences(aLightShader, index) {
let gl = glSys.get();
this.mColorRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Color");
this.mPosRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Position");
this.mNearRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Near");
this.mFarRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Far");
this.mIntensityRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Intensity");
this.mIsOnRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].IsOn");
}
- 定义一个简单的函数来更新
light_fs
片段着色器数组中灯光的开/关状态:
loadToShader(aCamera, aLight) {
let gl = glSys.get();
gl.uniform1i(this.mIsOnRef, aLight.isLightOn());
if (aLight.isLightOn()) {
let p = aCamera.wcPosToPixel(aLight.getPosition());
let n = aCamera.wcSizeToPixel(aLight.getNear());
let f = aCamera.wcSizeToPixel(aLight.getFar());
let c = aLight.getColor();
gl.uniform4fv(this.mColorRef, c);
gl.uniform3fv(this.mPosRef, vec3.fromValues(p[0], p[1], p[2]));
gl.uniform1f(this.mNearRef, n);
gl.uniform1f(this.mFarRef, f);
gl.uniform1f(this.mIntensityRef, aLight.getIntensity());
}
}
switchOffLight() {
let gl = glSys.get();
gl.uniform1i(this.mIsOnRef, false);
}
请注意,ShaderLightAt
类是为将光线加载到 GLSL 片段着色器中的特定数组元素而定义的。这是一个内部发动机操作。游戏程序员没有理由访问这个类,因此,不应该修改引擎访问文件index.js
来转发这个类的定义。
修改 LightShader 类
您现在必须修改LightShader
对象,以正确处理Light
对象和light_fs
片段着色器中的灯光阵列之间的通信:
-
首先编辑
light_shader.js
文件,导入ShaderLightAt
,然后移除的_loadToShader()
功能。光线信息向light_fs
片段着色器的实际加载现在由新定义的ShaderLightAt
对象处理。 -
修改构造函数来定义
mLights
,它是一个由ShaderLightAt
对象组成的数组,对应于在light_fs
片段着色器中定义的uLights
数组。需要注意的是,mLights
和uLights
数组的大小必须完全相同。
import ShaderLightAt from "./shader_light_at.js";
- 修改
activate()
函数,通过调用相应的loadToShader()
函数迭代并加载每个ShaderLightAt
对象的内容到light_fs
着色器。回想一下,GLSL 片段着色器要求 for 循环控制变量为常量。这意味着在每次调用light_fs
时,都会处理uLights
数组的所有元素。因此,确保关闭所有不用的灯非常重要。以下代码中的最后一个 while 循环确保了这一点:
constructor(vertexShaderPath, fragmentShaderPath) {
// Call super class constructor
super(vertexShaderPath, fragmentShaderPath);
this.mLights = null; // lights from the Renderable
this.mCamera = null; // camera to draw, need for WC to DC xform
//*******WARNING***************
// MUST correspond to GLSL uLight[] array size (for LightFS.glsl)
//*******WARNING********************
this.kGLSLuLightArraySize = 4; // must be the same as LightFS.glsl
this.mShaderLights = [];
let i, ls;
for (i = 0; i < this.kGLSLuLightArraySize; i++) {
ls = new ShaderLightAt(this.mCompiledShader, i);
this.mShaderLights.push(ls);
}
}
- 将
setCameraAndLight()
函数重命名为setCameraAndLights()
;除了设置相应的变量,检查以确保灯光数组大小不大于在light_fs
片段着色器中定义的数组大小。最后,记得更新sprite_shader.js
中相应的函数名。
activate(pixelColor, trsMatrix, cameraMatrix) {
// first call the super class' activate
super.activate(pixelColor, trsMatrix, cameraMatrix);
// now push the light information to the shader
let numLight = 0;
if (this.mLights !== null) {
while (numLight < this.mLights.length) {
this.mShaderLights[numLight].loadToShader(
this.mCamera, this.mLights[numLight]);
numLight++;
}
}
// switch off the left over ones.
while (numLight < this.kGLSLuLightArraySize) {
this.mShaderLights[numLight].switchOffLight(); // off the un-use
numLight++;
}
}
setCameraAndLights(c, l) {
this.mCamera = c;
this.mLights = l;
if (this.mLights.length > this.kGLSLuLightArraySize)
throw new Error ("Error: " ...);
}
修改 LightRenderable 类
你现在可以修改LightRenderable
类来支持多个光源:
-
在
LightRenderable
构造函数中,用一个数组替换单个光线引用变量: -
确保更新绘制功能以反映对多个光源的更改:
constructor(myTexture) {
super(myTexture);
super._setShader(defaultShaders.getLightShader());
// the light sources
this.mLights = [];
}
- 为灯光阵列定义相应的访问器函数:
draw(camera) {
this.mShader.setCameraAndLights(camera, this.mLights);
super.draw(camera);
}
getLightAt(index) { return this.mLights[index]; }
addLight(l) { this.mLights.push(l); }
用我的游戏测试光源
通过在引擎中正确集成多种灯光支持,您现在可以修改MyGame
来测试您的实现并检查结果。除了添加多个灯光到场景中,您还将添加控制每个灯光属性的功能。为了保持可读性,您将把灯光实例化和控制划分到单独的文件中。为了避免冗余和重复的代码列表,没有显示简单实现的细节。
-
修改
src/my_game
文件夹中的my_game_main.js
文件,以反映对构造函数、初始化函数、绘制函数和更新函数的更改。所有这些变化都围绕着通过灯光组处理多个灯光。 -
在
src/my_game
文件夹中,创建新文件my_game_lights.js
以从my_game_main.js
导入MyGame
类,并添加实例化和初始化灯光的功能。 -
在
src/my_game
文件夹中,创建从my_game_lights.js
导入的新文件my_game_light_control.js
,并继续添加灯光控制到MyGame
。 -
修改
my_game.js
以从my_game_light_control.js
导入,确保可以访问所有新定义的功能。
观察
运行项目以检查实现。尝试使用 0、1、2 和 3 键选择灯,并切换所选灯的开/关。请注意,游戏程序员可以控制哪个灯光照亮哪个对象:所有灯光照亮背景,而英雄只被灯光 0 和 3 照亮,左边的小兵只被灯光 1 和 3 照亮,右边的小兵只被灯光 2 和 3 照亮。
用 WASD 键移动Hero
对象,观察当它通过光源 0 的远*半径时,照明是如何变化的。选择光源 0(类型 0),按 C 键增加灯光的*半径。请注意,随着*半径接*远半径的值,照亮的圆边界边也会变得更清晰。最终,当*半径大于远半径时,你可以再次观察到边界处突然的亮度变化。您观察到的是违反了基本照明模型的隐含假设,即*半径总是小于远半径。这种确切的情况可以通过使用 N 键减小远半径来创建。
您可以使用箭头键移动光源,以观察光源的相加性。尝试更改光源的 z 位置及其*/远值,观察不同的 z/*/远设置如何实现相似的照明效果。特别是,尝试用 K/L 键调整光强度,以观察过饱和和几乎不明显的光照的影响。您可以继续按 L 键,直到强度变为负值,以创建一个从场景中移除颜色的源。场景中有两个恒定颜色的正方形,用于确认未被照亮的对象仍然可以被渲染。
漫反射和法线贴图
现在,您可以放置或移动许多光源,并控制目标区域的照明或阴影。但是,如果您运行之前的项目并移动其中一个光源,您可能会注意到一些特殊的效果。图 8-7 通过将左侧先前项目的照明结果与右侧您可能预期的照明进行比较,突出了这些效果。现在,参考左边的图片。首先,请注意*截止区域内的一般均匀照明,在该区域内,无法观察到点光源位置周围的预期亮点。其次,检查几何块的垂直面,并注意底面上的明亮照明,该照明明显位于光源的后面,或指向远离光源的方向。这两个特点在图 8-7 的右图中都没有。
虽然视觉上有些奇怪,但图 8-7 左边图像的结果在 2D 世界中是意料之中的。垂直面只是艺术家的再现,并且您的照明计算不考虑图像内容建议的几何轮廓。*面 2D 世界中的这种照明限制在本节中通过引入漫反射和法线贴图来*似曲面的法线向量而得以弥补。
图 8-7
左图:来自上一个项目。右图:预期照明
如图 8-8 左图所示,表面法向矢量、表面法线或法向矢量是垂直于给定表面元素的矢量。图 8-8 右图显示,在三维空间中,物体的表面法向量描述了物体的形状或轮廓。
图 8-8
物体的表面法向量
人类对光照的观察是来自光源的可见能量从物体表面反射并到达眼睛的结果。漫射面、粗糙面或朗伯面将光能均匀地反射到各个方向。漫射表面的示例包括典型的打印纸或无光涂漆表面。图 8-9 显示了照亮三个漫射面元素位置 A、B 和 c 的光源。首先,注意从被照亮位置朝向光源的方向被定义为该位置的光矢量。重要的是要注意到
矢量的方向总是朝向光源,并且这是一个大小为 1 的归一化矢量。
图 8-9 还举例说明了漫射照明或漫反射的大小。位置 A 不能从给定的光源接收任何能量,因为它的法向量垂直于它的光向量
或
。位置 B 可以接收所有的能量,因为它的法向量与它的光向量指向相同的方向,或者说
。一般来说,如位置 C 所示,漫射表面接收和反射的光能比例与其法线和光矢量之间的夹角余弦成正比,即
。在照明模型中,
计算的术语被称为漫射或朗伯分量。
图 8-9
法线和灯光向量以及漫射照明
人类视觉系统主要基于或漫射分量来推断 3D 几何形状轮廓。例如,图 8-10 显示了一个有(左图)和没有(右图)相应漫射组件的球体和圆环体(环形物体)。显然,在这两种情况下,物体的 3D 轮廓被具有漫射分量的图像的左侧版本捕获。
图 8-10
包含和不包含漫射组件的 3D 对象示例
在 2D 世界中,就像你的游戏引擎一样,所有的物体都用 2D 图像或纹理来表示。因为所有对象都是定义在 xy *面上的 2D 纹理图像,所以所有对象的法向量都是相同的:z 方向的向量。缺少物体的不同法向量意味着不可能为物体计算不同的漫射分量。幸运的是,与纹理贴图解决每个几何体只有一种颜色的限制类似,法线贴图可以解决每个几何体只有一个法线向量的问题。
图 8-11 显示了法线贴图背后的思想,除了彩色纹理图像,还需要相应的法线纹理图像。图 8-11 的左图是典型的彩色纹理图像,右图是左图上高亮显示的正方形的放大图像。请再次注意,法线贴图中涉及到两个图像:彩色纹理图像,其中纹理的 RGB 通道记录了对象的颜色(图 8-11 的右图底部)和相应的法线纹理图像,其中 RGB 通道记录了彩色纹理中相应对象的法线矢量的 x、y 和 z 值(右图顶部)。
图 8-11
具有两个纹理图像的法线贴图:法线和彩色纹理
图 8-12 捕捉了图 8-11 右图中标注的三个对应位置,法线纹理上的位置 n 1 ,n 2 ,n 3 以及颜色纹理上的对应位置 c 1 ,c 2 ,c 3 的视图,以说明法线贴图的细节。图 8-12 的底层显示颜色纹理记录颜色,颜色 c 1 ,c 2 ,c 3 在这三个位置采样。图 8-12 中间层显示法线纹理的 RGB 分量记录了物体在相应颜色纹理位置的法线矢量 xyz 值。图 8-12 的顶层显示,当被光源照射时,通过正确计算和显示项,人类视觉系统将感知到倾斜的轮廓。
图 8-12
具有两个纹理图像的法线贴图:法线和彩色纹理
总之,法线纹理贴图或法线贴图是存储法线向量信息而不是通常的颜色信息的纹理贴图。法线贴图的每个纹理元素对 RGB 通道中法线向量的 xyz 值进行编码。与使用颜色纹理显示法线贴图纹理元素不同,纹理元素纯粹用于计算表面如何与光线交互。以这种方式,代替指向 z 方向的恒定法向量,当正方形被法线映射时,被渲染的每个像素的法向量将由来自法线贴图的纹理元素来定义,并且可以用于计算漫射分量。因此,渲染图像将显示类似于法线贴图中编码的形状的轮廓。
在上一个项目中,您扩展了引擎以支持多种光源。在本节中,您将定义IllumShader
类来概括一个LightShader
以支持基于法线贴图的漫射组件的计算。
法线贴图和照明着色器项目
这个项目演示了如何将法线贴图集成到你的游戏引擎中,并使用结果来计算物体的漫反射部分。你可以在图 8-13 中看到这个项目运行的例子。这个项目的源代码位于chapter8/8.4.normal_maps_and_illumination_shaders
文件夹中。
图 8-13
运行法线贴图和照明着色器项目
该项目的控件与之前的项目相同:
-
WASD 键:移动屏幕上的英雄角色
-
数字键 0、1、2、3 :选择对应的光源
-
箭头键:移动当前选中的灯
-
Z/X 键:增加/减少灯的 Z 位置
-
C/V 和 B/N 键:增加/减少所选光线的远*截止距离
-
K/L 键:增加/减少所选光线的强度
-
H 键:打开/关闭选择的灯
该项目的目标如下:
-
理解和使用法线贴图
-
在游戏引擎中实现法线贴图作为纹理
-
实现支持漫射组件照明的 GLSL 着色器
-
检查照明模型中的漫射组件
您可以在assets
文件夹中找到以下外部资源文件。fonts
文件夹包含默认的系统字体,两张纹理图像,以及对应纹理图像的两张法线贴图minion_sprite.png
和bg.png
,以及对应的法线贴图minion_sprite_normal.png
和bg_normal.png
。和之前的项目一样,对象是minion_sprite.png
的 sprite 元素,背景用bg.png
表示。
Note
基于minion_sprite.png
图像,从 http://cpetry.github.io/NormalMap-Online/
通过算法生成minion_sprite_normal.png
法线贴图。
创建 GLSL 照明片段着色器
与前面的项目一样,法线贴图的集成将从 GLSL 着色器的实现开始。请注意,这个新的着色器将与您的light_fs.glsl
非常相似,但包含了法线贴图和漫射计算支持。为了确保支持没有法线贴图的简单照明,您将创建一个新的 GLSL 片段着色器。
-
从
light_fs.glsl
开始复制并粘贴到src/glsl_shaders
文件夹中的一个新文件illum_fs.glsl
中。 -
编辑
illum_fs.glsl
文件,添加一个sampler2D
对象uNormalSampler
,对法线贴图进行采样: -
修改
LightEffect()
函数以接收法向量参数N
。这个法向量N
被认为是归一化的,大小为 1,并将用于漫射分量的计算。输入代码以计算
向量,记住标准化向量,并使用
的结果相应地缩放光线
strength
。
uniform sampler2D uSampler;
uniform sampler2D uNormalSampler;
... identical to the variables declared in light_fs.glsl ...
- 编辑
main()
功能,用uSampler
从彩色纹理中取样,用uNormalSampler
从普通纹理中取样。请记住,法线贴图为您提供了一个表示给定位置的表面元素的法向量的向量。因为 xyz 法线向量值以 0 到 1 的 RGB 颜色格式存储,所以采样的法线贴图结果必须缩放并偏移到-1 到 1 的范围。此外,回想一下,纹理 uv 坐标可以用向上或向下增加的 v 方向来定义。在这种情况下,根据法线贴图的 v 方向,您可能还需要翻转采样法线贴图值的 y 方向。然后,标准化的法向量N
被传递给LightEffect()
函数,用于照明计算。
vec4 LightEffect(Light lgt, vec3 N) {
vec4 result = vec4(0);
float strength = 0.0;
vec3 L = lgt.Position.xyz - gl_FragCoord.xyz;
float dist = length(L);
if (dist <= lgt.Far) {
if (dist <= lgt.Near) {
... identical to previous code ...
}
L = L / dist; // To normalize L
// Not calling normalize() function to avoid re-computing
// "dist". This is computationally more efficient.
float NdotL = max(0.0, dot(N, L));
strength *= NdotL;
}
result = strength * lgt.Intensity * lgt.Color;
return result;
}
void main(void) {
// simple tint based on uPixelColor setting
vec4 textureMapColor = texture2D(uSampler, vTexCoord);
vec4 normal = texture2D(uNormalSampler, vTexCoord); // same UV
vec4 normalMap = (2.0 * normal) - 1.0;
//
// normalMap.y = -normalMap.y; // flip Y
// depending on the normal map you work with,
// this may or may not be flipped
//
vec3 N = normalize(normalMap.xyz);
vec4 lgtResult = uGlobalAmbientColor * uGlobalAmbientIntensity;
// now decide if we should illuminate by the light
if (textureMapColor.a > 0.0) {
for (int i=0; i<kGLSLuLightArraySize; i++) {
if (uLights[i].IsOn) {
lgtResult += LightEffect(uLights[i], N);
}
}
}
... identical to previous code ...
}
Note
法线贴图可以在各种不同的布局中创建,其中 x 或 y 可能需要翻转以正确表示所需的表面几何图形。这完全取决于创建地图的工具或艺术家。
定义 IllumShader 类
使用支持法线贴图的Illum_fs
片段着色器,您可以创建 JavaScript IllumShader
类来与之交互:
-
在
src/engine/shaders
文件夹中,创建illum_shader.js
,并将IllumShader
定义为LightShader
的子类,以利用与光源相关的功能。在构造函数中,定义一个变量mNormalSamplerRef
,以维护对illum_fs
片段着色器中普通采样器的引用。不要忘记导出类。 -
覆盖并扩展
activate()
函数,将普通纹理采样器引用绑定到 WebGL 纹理单元 1。你可能还记得第五章中的TextureShader
将颜色纹理采样器绑定到纹理单元 0。通过将法线贴图绑定到纹理单元 1,WebGL 纹理系统可以同时处理两个活动纹理:单元 0 和单元 1。正如将在下一小节中讨论的,通过texture
模块配置 WebGL 来激活相应目的的适当纹理单元是很重要的:颜色与普通纹理映射。
import LightShader from "./light_shader.js";
import * as glSys from "../core/gl.js";
class IllumShader extends LightShader {
constructor(vertexShaderPath, fragmentShaderPath) {
super(vertexShaderPath, fragmentShaderPath);
let gl = glSys.get();
// reference to the normal map sampler
this.mNormalSamplerRef = gl.getUniformLocation(
this.mCompiledShader, "uNormalSampler");
}
... implementation to follow ...
}
export default IllumShader;
activate(pixelColor, trsMatrix, cameraMatrix) {
// first call the super class' activate
super.activate(pixelColor, trsMatrix, cameraMatrix);
let gl = glSys.get();
gl.uniform1i(this.mNormalSamplerRef, 1); // binds to texture unit 1
// do not need to set up texture coordinate buffer
// as we are going to use the ones from the sprite texture
// in the fragment shader
}
Note
WebGL 支持在渲染过程中同时激活多个纹理单元。根据 GPU 的不同,在一次渲染过程中,至少有八个纹理单元可以同时处于活动状态。在本书中,您将在渲染过程中仅激活两个纹理单元:一个用于彩色纹理,另一个用于普通纹理。
修改纹理模块
到目前为止,您已经将颜色纹理贴图绑定到 WebGL 纹理单元 0。添加了正常纹理后,绑定到 WebGL 纹理系统的单元现在必须参数化。幸运的是,这是一个简单的改变。
通过打开src/engine/resources
文件夹中的texture.js
修改texture
模块。编辑activate()
函数以接受第二个参数,即要绑定到的 WebGL 纹理单元。请注意,这是一个可选参数,默认值设置为纹理单位 0。这使得对activate()
函数的任何现有调用都不需要改变。
function activate(textureName, textureUnit = glSys.get().TEXTURE0) {
let gl = glSys.get();
let texInfo = get(textureName);
// Binds texture reference to current webGL texture functionality
gl.activeTexture(textureUnit); // activate the WebGL texture unit
gl.bindTexture(gl.TEXTURE_2D, texInfo.mGLTexID);
... identical to previous code ...
}
创建 IllumRenderable 类
现在可以定义照明Renderable
类来利用新创建的照明着色器:
- 首先在
src/engine/renderables
文件夹中创建illum_renderable.js
,将IllumRenderable
类定义为LightRenderable
的子类,并初始化一个mNormalMap
实例变量来记录法线贴图 ID。IllumRenderable
对象使用两个纹理贴图:myTexture
用于颜色纹理贴图,myNormalMap
用于法线贴图。注意,这两个纹理贴图共享在SpriteShader
的mTexCoordBuffer
中定义的相同纹理坐标。这种纹理坐标的共享隐含地假设物体的几何形状在彩色纹理图中被描绘,并且法线纹理图被导出以捕捉物体的轮廓,这几乎总是的情况。最后,不要忘记导出这个类。
import * as texture from "../resources/texture.js";
import * as glSys from "../core/gl.js";
import LightRenderable from "./light_renderable.js";
import * as defaultShaders from "../core/shader_resources.js";
class IllumRenderable extends LightRenderable {
constructor(myTexture, myNormalMap) {
super(myTexture);
super._setShader(defaultShaders.getIllumShader());
// here is the normal map resource id
this.mNormalMap = myNormalMap;
// Normal map texture coordinate is same as sprite sheet
// This means, the normal map MUST be based on the sprite sheet
}
... implementation to follow ...
}
export default IllumRenderable;
Note
再次强调,法线纹理贴图是一个必须由艺术家显式创建或者由适当的程序通过算法创建的图像,这一点很重要。使用常规颜色纹理贴图图像作为普通纹理贴图通常是行不通的。
- 接下来,在调用超类的
draw()
方法之前,覆盖draw()
函数来激活法线贴图。请注意texture.activate()
函数调用的第二个参数,其中明确指定了 WebGL 纹理单元 1。这样,随着IllumShader
将uNormalSampler
链接到 WebGL 纹理单元 1 并且illum_fs
将uNormalSampler
采样为法线贴图,您的引擎现在支持正确的法线贴图。
draw(camera) {
texture.activate(this.mNormalMap, glSys.get().TEXTURE1);
// Here the normal map texture coordinate is copied from those of
// the corresponding sprite sheet
super.draw(camera);
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
定义默认 IllumShader 实例
与引擎中的所有其他着色器类似,IllumShader
的默认实例必须定义为共享。定义默认的IllumShader
实例的代码与本章前面介绍的LightShader
的代码相同,只是替换了相应的变量名和数据类型。请参考“定义一个默认的 LightShader 实例”小节和src/engine/core
文件夹中的shader_resources.js
源代码文件了解详情。
测试法线贴图
测试新集成的法线贴图功能必须包括验证非法线贴图简单颜色纹理是否正常工作。为了实现这一点,背景、英雄和左仆从将被创建为新定义的IllumRenderable
对象,而右仆从将保持为LightRenderable
对象。
修改英雄和奴才
Hero
和Minion
对象应该被实例化为新定义的IllumRenderable
对象:
-
编辑
src/my_game/objects
中的hero.js
,修改Hero
类的构造函数,用IllumRenderable
实例化游戏对象: -
在同一个文件夹中,编辑
minion.js
以修改Minion
类的构造函数,从而在正常纹理贴图存在时,有条件地用LightRenderable
或IllumRenderable
实例化游戏对象:
constructor(spriteTexture, normalMap) {
super(null);
this.kDelta = 0.3;
this.mRenderComponent = new engine.IllumRenderable(
spriteTexture, normalMap);
this.mRenderComponent.setColor([1, 1, 1, 0]);
... identical to previous code ...
}
constructor(spriteTexture, normalMap, atX, atY) {
super(null);
this.kDelta = 0.2;
if (normalMap === null) {
this.mRenderComponent = new engine.LightRenderable(
spriteTexture);
} else {
this.mRenderComponent = new engine.IllumRenderable(
spriteTexture, normalMap);
}
... identical to previous code ...
}
修改我的游戏
您现在可以修改MyGame
来测试和显示您的照明着色器的实现。修改src/my_game
文件夹中的my_game_main.js
文件,加载和卸载新的法线贴图,并用法线贴图文件创建Hero
和Minion
对象。如前所述,所涉及的变化是简单明了的,而且相对来说是最小的;因此,这里不显示细节。
观察
现在项目已经完成,您可以运行它并检查您的结果,以观察漫射照明的效果。请注意,Hero
、左边的Minion
和背景物体是用漫射计算照亮的,看起来从灯光中提供了更多的深度。这些物体的颜色和阴影有更多的变化。
您可以确认在图 8-7 的左图中观察到的特殊效果已经解决。为了更清楚地观察,关闭除灯 2 以外的所有其他灯(在 H 键后键入灯号)。现在,移动灯光位置(用箭头键)照亮Hero
字符后面的几何块;你可以用 WASD 键把它移走。验证您正在查看的结果与图 8-7 右图中的结果相似。您应该能够清楚地观察到对应于点光源位置的最亮点。此外,请注意,只有当灯光位置在面前面或漫射项为正时,块的底面才会被照亮。
通常,移动光源时,观察垂直方向的面,例如,几何块或间隙的侧面。当灯光位置移动越过这样的边界时,项的符号将翻转,相应的表面照明将经历剧烈的变化(从暗到亮,反之亦然)。为了获得更生动的效果,将灯光的 z 高度(使用 X 键)降低到小于 5 的值。使用法线贴图和漫射计算,您已经将静态背景图像转换为由复杂的 3D 几何形状定义的背景。尝试移动其他光源,并观察光源穿过所有对象时,这些对象上的照明变化。
最后,Hero
和左Minion
略微像素化和粗糙的外观证明了一个事实,即这些物体的法线贴图是通过算法从相应的彩色图像中生成的,并且这些贴图不是由艺术家创建的。
镜面反射和材料
您实现的漫射照明适用于模拟无光泽表面的照明,如典型的打印纸、许多粉刷过的内墙,甚至是传统的黑板。Phong 照明模型通过引入镜面反射项来模拟光源在光亮表面上的反射,从而扩展了这种简单的漫射照明。图 8-14 显示了一个三个球体的例子,一个简单的无光泽球体,一个具有适度高光的球体,以及一个高度抛光的球体。右边两个球体上的高光是 Phong 镜面反射项的结果。
图 8-14
物体的镜面反射和闪光
图 8-15 显示了给定一个光亮或反射表面,如抛光地板或抛光塑料,当眼睛或摄像机位于光源的反射方向时,光源的反射将是可见的。光源在光亮表面上的反射被称为镜面反射、镜面高光或镜面度。
图 8-15
镜面反射:光源的反射
根据实际经验,即使眼睛的观察方向与光源的反射方向不完全一致,镜面高光也是可见的。如图 8-16 所示,其中矢量是光线矢量
的反射方向,即使观察方向
与
矢量不完全一致,物体上的镜面高光也是可见的。现实生活的经验也告诉你
离
越远,或者角度-α越大,你就越不可能观察到光的反射。事实上,你知道当α为零时,你会观察到最大的光反射,当α为 90°或当
和
垂直时,你会观察到零光反射。
图 8-16
Phong 镜面反射模型
Phong 照明模型用一个项模拟镜面反射的特征。当
和
对准时,或者当α=0 时,镜面反射率项计算为 1,当
和
之间的间隔增加到 90°或者当α= 90°时,根据余弦函数,镜面反射率项下降到 0。功率 n ,被称为闪亮度,描述了当α增加时镜面高光滚降的速度。 n 值越大,余弦函数随着α的增加下降得越快,镜面高光下降得越快,表面看起来就越有光泽。例如,在图 8-14 中,左、中、右球体对应的 n 值分别为 0、5 和 30。
虽然项有效地模拟了镜面高光,但是为每个着色像素计算
向量所涉及的成本可能是巨大的。如图 8-17 所示,中间矢量
定义为
和
矢量的*均值。据观察,
和
之间的角度β也可用于表征镜面反射。虽然略有不同,
产生的结果与
相似,但每像素计算成本更低。中途向量将用于在您的实现中*似镜面反射率。
图 8-17
中途向量
如图 8-18 所示,您将实现的 Phong 照明模型的变体包括通过三个不同的术语模拟场景中三个参与元素的相互作用。三个参与元素是全局环境照明、光源和被照明对象的材质属性。前面的例子已经解释了前两个:全局环境照明和光源。这样,为了支持 Phong 光照模型,一个物体的材质属性可以用Ka, K d , K s , n 来表示。它们代表三种颜色,分别代表环境反射率、漫反射率和镜面反射率,以及一个表示对象亮度的浮点数。用全局环境光强度, I a ,和颜色, C a ,和光源强度, I L ,和颜色, C L ,Phong 光照模型的三个术语如下
-
环境术语:IaCaKa
-
扩散术语 :
-
镜面反射项 :
请注意,前两个术语,环境和漫射术语,已经在前面的示例中涵盖。前一个示例中的illum_fs
GLSL 片段着色器实现了这两个项,具有灯光距离衰减,并且没有 K a 和 K d 材质属性。该项目指导您构建对每对象材质属性的支持,并使用IllumShader
/ IllumRenderable
对象对中的引擎支持在illum_fs
GLSL 着色器中完成 Phong 光照模型实现。
图 8-18
Phong 光照模型
游戏引擎和 GLSL 着色器中材质的集成
为了实现 Phong 照明模型,封装图 8-18 中表面材质属性的Material
类必须由每个IllumRenderable
对象定义和引用,该对象将由相应的illum_fs
片段着色器进行着色。图 8-19 说明了在您的实现中,一个新的ShaderMaterial
对象将在IllumShader
中被定义和引用,以将Material
对象的内容加载到illum_fs
GLSL 片段着色器中。
图 8-19
材料支持
材料和镜面项目
这个项目演示了一个 Phong 光照模型版本的实现,它利用了法线贴图和摄像机的位置。它还实现了一个系统,该系统存储并转发每个对象的材质属性到 GLSL 着色器,用于 Phong 光照计算。你可以在图 8-20 中看到项目运行的例子。这个项目的源代码位于chapter8/8.5.material_and_specularity
文件夹中。
图 8-20
运行材料和镜面反射项目
该项目的主要控制与前一个项目相同:
- WASD 键:移动屏幕上的英雄角色
照明控制:
-
数字键 0、1、2、3 :选择对应的光源
-
箭头键:移动当前选中的灯
-
Z/X 键:增加/减少灯的 Z 位置
-
C/V 和 B/N 键:增加/减少所选光线的远*截止距离
-
K/L 键:增加/减少所选光线的强度
-
H 键:打开/关闭选择的灯
材质特性控件是该项目的新功能:
-
数字键 5 和 6 :选择左边的仆人和英雄
-
数字键 7、8、9 :选择Ka, K d , K s 所选角色(左仆从或英雄)的材料属性
-
E/R、T/Y、U/I 键:增加/减少所选材质属性的红色、绿色、蓝色通道
-
O/P 键:增加/减少所选材料属性的亮度
该项目的目标如下:
-
为了理解镜面反射和 Phong 镜面反射术语
-
在 GLSL 碎片着色器中实现镜面高光照明
-
理解并体验控制被照明物体的
Material
-
检查照明图像中的镜面高光
修改 GLSL 照明片段着色器
与之前的项目一样,您将从在 GLSL illum_fs
片段着色器中实现实际的照明模型开始:
-
编辑
illum_fs.glsl
文件并定义一个变量uCameraPosition
,用于存储摄像机位置。该位置用于计算矢量,即观察方向。现在,创建一个材质
struct
和一个相应的变量uMaterial
,用于存储每个对象的材质属性。注意变量名Ka
、Kd
、Ks
和n
与图 8-18 中 Phong 光照模型中术语的对应关系。 -
为了支持可读性,照明模型中的数学术语将被定义到单独的函数中。您将从定义
DistanceDropOff()
函数开始,执行与前一个项目完全相同的*/远截止计算。
// for supporting a simple Phong-like illumination model
uniform vec3 uCameraPosition; // for computing the V-vector
// material properties
struct Material {
vec4 Ka; // simple boosting of color
vec4 Kd; // Diffuse
vec4 Ks; // Specular
float Shininess; // this is the "n"
};
uniform Material uMaterial;
- 定义计算扩散项的函数。请注意,纹理贴图颜色应用于漫射项。
// Computes the L-vector, returns strength
float DistanceDropOff(Light lgt, float dist) {
float strength = 0.0;
if (dist <= lgt.Far) {
if (dist <= lgt.Near)
strength = 1.0; // no attenuation
else {
// simple quadratic drop off
float n = dist - lgt.Near;
float d = lgt.Far - lgt.Near;
strength = smoothstep(0.0, 1.0, 1.0-(n*n)/(d*d));
// blended attenuation
}
}
return strength;
}
- 定义计算镜面反射项的函数。通过归一化从当前像素位置
gl_FragCoord
减去uCameraPosition
的结果来计算向量
V
。注意这个操作是在像素空间中执行的,并且IllumShader
/IllumRenderable
对象对必须在发送信息之前将 WC 摄像机位置转换到像素空间,这一点很重要。
vec4 DiffuseResult(vec3 N, vec3 L, vec4 textureMapColor) {
return uMaterial.Kd * max(0.0, dot(N, L)) * textureMapColor;
}
- 现在,您可以实现 Phong 照明模型来累积漫反射和镜面反射项。请注意,图 8-18 中的
lgt.Intensity
、 I L 和lgt.Color
、 C L 被分解并乘以漫反射和镜面反射结果的总和。基于*/远截止计算的光强度的缩放,strength
是该实现与图 8-18 中列出的漫射/镜面反射项之间的唯一区别。
vec4 SpecularResult(vec3 N, vec3 L) {
vec3 V = normalize(uCameraPosition - gl_FragCoord.xyz);
vec3 H = (L + V) * 0.5;
return uMaterial.Ks * pow(max(0.0, dot(N, H)), uMaterial.Shininess);
}
- 通过考虑环境项并循环所有定义的光源以累积
ShadedResults()
,完成main()
函数中的实现。主函数的大部分类似于前一个项目中的illum_fs.glsl
文件中的函数。唯一重要的区别用粗体突出显示。
vec4 ShadedResult(Light lgt, vec3 N, vec4 textureMapColor) {
vec3 L = lgt.Position.xyz - gl_FragCoord.xyz;
float dist = length(L);
L = L / dist;
float strength = DistanceDropOff(lgt, dist);
vec4 diffuse = DiffuseResult(N, L, textureMapColor);
vec4 specular = SpecularResult(N, L);
vec4 result = strength * lgt.Intensity *
lgt.Color * (diffuse + specular);
return result;
}
void main(void) {
... identical to previous code ...
vec3 N = normalize(normalMap.xyz);
vec4 shadedResult = uGlobalAmbientIntensity *
uGlobalAmbientColor * uMaterial.Ka;
// now decide if we should illuminate by the light
if (textureMapColor.a > 0.0) {
for (int i=0; i<kGLSLuLightArraySize; i++) {
if (uLights[i].IsOn) {
shadedResult += ShadedResult(
uLights[i], N, textureMapColor);
}
}
}
... identical to previous code ...
}
定义材料类别
如上所述,需要一个简单的Material
类来封装 Phong 照明模型的 per- Renderable
材质属性:
-
在
src/engine
文件夹中创建material.js
,定义Material
类,在构造函数中,初始化图 8-18 中表面材质属性中定义的变量。请注意,环境光、漫反射和镜面反射(Ka
、Kd
和Ks
)是颜色,而光泽是浮点数。 -
为变量提供简单的 get 和 set 访问器:
class Material {
constructor() {
this.mKa = vec4.fromValues(0.0, 0.0, 0.0, 0);
this.mKs = vec4.fromValues(0.2, 0.2, 0.2, 1);
this.mKd = vec4.fromValues(1.0, 1.0, 1.0, 1);
this.mShininess = 20;
}
... implementation to follow ...
}
export default Material;
setAmbient(a) { this.mKa = vec4.clone(a); }
getAmbient() { return this.mKa; }
setDiffuse(d) { this.mKd = vec4.clone(d); }
getDiffuse() { return this.mKd; }
setSpecular(s) { this.mKs = vec4.clone(s); }
getSpecular() { return this.mKs; }
setShininess(s) { this.mShininess = s; }
getShininess() { return this.mShininess; }
请注意,Material
类被设计用来表示Renderable
对象的材质属性,并且必须是游戏程序员可以访问的。因此,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
定义 ShaderMaterial 类
类似于定义ShaderLightAt
类来将数组索引处的光源信息传递给 GLSL 片段着色器,应该定义一个新的ShaderMaterial
类来将Material
的内容传递给 GLSL illum_fs
着色器。类似于ShaderLightAt
的实现,ShaderMaterial
类也将在src/engine/shaders
文件夹中定义。
-
在
src/engine/shaders
文件夹中创建shader_material.js
,定义ShaderMaterial
类,在构造函数中,初始化变量作为对illum_fs
GLSL 着色器中的环境、漫反射、镜面反射和光亮的引用。 -
定义
loadToShader()
函数将Material
的内容推送到 GLSL 着色器:
import * as glSys from "../core/gl.js";
class ShaderMaterial {
constructor(aIllumShader) {
let gl = glSys.get();
this.mKaRef = gl.getUniformLocation(
aIllumShader, "uMaterial.Ka");
this.mKdRef = gl.getUniformLocation(
aIllumShader, "uMaterial.Kd");
this.mKsRef = gl.getUniformLocation(
aIllumShader, "uMaterial.Ks");
this.mShineRef = gl.getUniformLocation(
aIllumShader, "uMaterial.Shininess");
}
... implementation to follow ...
}
export default ShaderMaterial;
loadToShader(aMaterial) {
let gl = glSys.get();
gl.uniform4fv(this.mKaRef, aMaterial.getAmbient());
gl.uniform4fv(this.mKdRef, aMaterial.getDiffuse());
gl.uniform4fv(this.mKsRef, aMaterial.getSpecular());
gl.uniform1f(this.mShineRef, aMaterial.getShininess());
}
类似于ShaderLightAt
类,ShaderMaterial
类被定义用于将材质加载到 GLSL 片段着色器。这是一个内部发动机操作。游戏程序员没有理由访问这个类,因此,不应该修改引擎访问文件index.js
来转发这个类的定义。
修改 IllumShader 类
回想一下,IllumShader
类是引擎与相应的 GLSL illum_fs
片段着色器的接口。现在必须修改IllumShader
类来支持illum_fs
中新定义的 Phong 照明功能。这种支持可以通过修改IllumShader
来定义一个ShaderMaterial
对象,以将Material
对象的内容加载到illum_fs
片段着色器来实现。
-
编辑
src/engine/shaders
中的illum_shader.js
导入ShaderMaterial
,修改构造函数定义新变量mMaterial
和mCameraPos
,支持 Phong 光照计算。然后定义变量mMaterialLoader
和mCameraPosRef
,用于保存引用和将相应内容加载到着色器中的统一变量。 -
修改
activate()
函数,将材质和相机位置加载到illum_fs
片段着色器:
import ShaderMaterial from "./shader_material.js";
constructor(vertexShaderPath, fragmentShaderPath) {
// Call super class constructor
super(vertexShaderPath, fragmentShaderPath);
// this is the material property of the Renderable
this.mMaterial = null;
this.mMaterialLoader = new ShaderMaterial(this.mCompiledShader);
let gl = glSys.get();
// Reference to the camera position
this.mCameraPos = null; // points to a vec3
this.mCameraPosRef = gl.getUniformLocation(
this.mCompiledShader, "uCameraPosition");
// reference to the normal map sampler
this.mNormalSamplerRef = gl.getUniformLocation(
this.mCompiledShader, "uNormalSampler");
}
- 定义
setMaterialAndCameraPos()
函数,为 Phong 照明计算设置相应的变量:
activate(pixelColor, trsMatrix, cameraMatrix) {
// first call the super class' activate
super.activate(pixelColor, trsMatrix, cameraMatrix);
let gl = glSys.get();
gl.uniform1i(this.mNormalSamplerRef, 1); // binds to texture unit 1
this.mMaterialLoader.loadToShader(this.mMaterial);
gl.uniform3fv(this.mCameraPosRef, this.mCameraPos);
}
setMaterialAndCameraPos(m, p) {
this.mMaterial = m;
this.mCameraPos = p;
}
修改 IllumRenderable 类
您现在可以修改IllumRenderable
类来包含一个材质属性并正确支持IllumShader
。这是一个简单的改变。
-
编辑
src/engine/renderables
文件夹中的illum_renderable.js
,修改构造函数实例化一个新的Material
对象: -
更新
draw()
函数,在实际渲染之前将材质和相机位置设置到着色器。注意,在对camera.getWCCenterInPixelSpace()
的调用中,摄像机位置被正确地转换到像素空间。
import Material from "../material.js";
constructor(myTexture, myNormalMap) {
... identical to previous code ...
// Material for this Renderable
this.mMaterial = new Material();
}
- 为 material 对象定义一个简单的访问器:
draw(camera) {
texture.activate(this.mNormalMap, glSys.get().TEXTURE1);
this.mShader.setMaterialAndCameraPos(
this.mMaterial, camera.getWCCenterInPixelSpace());
super.draw(camera);
}
getMaterial() { return this.mMaterial; }
修改相机类
正如你在illum_fs
片段着色器实现中看到的,计算向量所需的摄像机位置必须在像素空间中。必须修改
Camera
对象来提供这样的信息。由于Camera
对象将它的位置存储在 WC 空间中,所以这个位置必须被转换到每个IllumRenderable
对象渲染的像素空间中。
一个场景中可能会有大量的IllumRenderable
物体,一旦开始渲染就无法改变相机位置。这些观察表明,像素空间相机位置应该被计算一次,并且对于每个绘制周期被缓存。在简单光照着色器项目中定义的PerRenderCache
类专门用于缓存每一个绘制周期的信息,是缓存像素空间相机位置的理想选择。
-
编辑
camera_main.js
文件并在PerRenderCache
中添加一个vec3
来缓存摄像机在像素空间中的位置: -
在
Camera
构造函数中,定义一个 z 变量来模拟Camera
对象和其余Renderable
对象之间的距离。这第三条信息表示深度,并且是照度计算所需要的。
class PerRenderCache {
// Information to be updated once per render for efficiency concerns
constructor() {
this.mWCToPixelRatio = 1; // WC to pixel transformation
this.mCameraOrgX = 1; // Lower-left corner of camera in WC
this.mCameraOrgY = 1;
this.mCameraPosInPixelSpace = vec3.fromValues(0, 0, 0);
}
}
- 在
setViewAndCameraMatrix()
函数的步骤 B4 中,调用wcPosToPixel()
函数将摄像机的位置转换到 3D 像素空间并缓存计算结果:
This.kCameraZ = 10; // this is for illumination computation
- 为像素空间中的相机位置定义访问器:
// Step B4: compute and cache per-rendering information
this.mRenderCache.mWCToPixelRatio =
this.mViewport[eViewport.eWidth] / this.getWCWidth();
this.mRenderCache.mCameraOrgX = center[0] – (this.getWCWidth() / 2);
this.mRenderCache.mCameraOrgY = center[1] – (this.getWCHeight() / 2);
let p = this.wcPosToPixel(this.getWCCenter());
this.mRenderCache.mCameraPosInPixelSpace[0] = p[0];
this.mRenderCache.mCameraPosInPixelSpace[1] = p[1];
this.mRenderCache.mCameraPosInPixelSpace[2] =
this.fakeZInPixelSpace(this.kCameraZ);
getWCCenterInPixelSpace() {
return this.mRenderCache.mCameraPosInPixelSpace; }
测试镜面反射
现在可以测试 Phong 照明模型的实现,并观察改变对象的材质属性和镜面反射的效果。由于背景、Hero
和左Minion
已经是IllumRenderable
对象的实例,这三个对象现在将显示镜面反射。为了保证镜面反射的突出,在init()
功能中将背景物体的镜面材质属性Ks
设置为鲜红色。
定义了一个新功能_selectCharacter()
,允许用户使用Hero
或左侧Minion
对象的材料属性。文件my_game_material_control.js
实现了用于控制所选材料属性的实际用户交互。
观察
您可以运行项目并交互控制当前选定对象的材质属性(键入 5 键选择左边的Minion
,键入 6 键选择Hero
)。默认情况下,选择Hero
对象的材质属性。您可以尝试通过按 E/R、T/Y 或 U/I 键来更改漫射 RGB 分量。请注意,您可以同时按多个键来同时更改多个颜色通道。
背景图像的法线贴图是仔细生成的,因此最适合检查镜面效果。您可以在背景图像中沿垂直边界观察到红色高光。如果你不确定,注意背景图像的右上区域,选择灯光 3(键入 3 键),并切换开/关开关(键入 H 键)。请注意,随着灯光从关闭切换到打开,整个右上区域变得更亮,沿垂直边界有一个红色高亮显示。这个红色的亮点是光线 3 向相机的反射。现在,打开灯 3,向左和向右移动它(左/右箭头键)。观察高光是如何随着中间向量和面法线向量
之间的角度变化而增强然后减弱的。
您也可以在Hero
上调整材质以观察镜面反射度。现在,选择Hero
对象(键入 6 键),将其漫反射材质属性(同时按下 R、Y 和 I 键)降低到 0.2 左右,并将镜面反射属性(键入 9 以选择镜面反射,然后同时按下 E、T 和 U 键)增加到超过 1 的值。有了这个设置,漫射项减少了,镜面高光得到了强调,你可以观察到一个带有明亮高光点的黑色Hero
图形。如果您不确定,请尝试切换灯 0(键入 0 键)开/关(键入 H 键)。此时,您可以按住 P 键来降低亮度 n 的值。随着 n 值的降低,您可以观察到高亮显示的点的大小增加,同时这些点的亮度降低。如图 8-14 的中间球体所示,较小的 n 值对应于抛光程度较低的表面,该表面通常呈现面积较大但强度较低的高光。
相对较小的物体,如Hero
,不会占据很多像素;相关联的高光可能跨越甚至更少数量的像素,并且可能难以观察。镜面高光可以传达微妙而重要的效果;然而,掌握它的用法也很有挑战性。
光源类型
在这一点上,你的游戏引擎支持由单一类型的灯光,点光源的许多实例照明。点光源的行为很像真实世界中的灯泡。它从具有远*半径的单一位置照明,在该位置物体可以被光完全、部分或完全照亮。在大多数游戏引擎中,还有另外两种常见的灯光类型:*行光和聚光灯。
与点光源相反,*行光没有光源位置或范围。相反,它以特定的方向照亮了一切。虽然这些特性看起来不直观,但它们非常适合一般的背景照明。现实世界就是这样。白天,一般环境由太阳照明,其中来自太阳的光线可以方便地建模为*行光。从地球的角度看,来自太阳的光线实际上是*行的,来自一个固定的方向,这些光线照亮了一切。*行光是一种简单的光类型,只需要一个方向变量,没有距离衰减。*行光通常用作照亮整个场景的全局光。
聚光灯模拟了一个带有锥形灯罩的台灯。如图 8-21 所示,聚光灯是由一个指向特定方向(光线方向)的圆锥体包围的点光源,具有内锥角和外锥角的角度衰减参数。类似于距离衰减的远*半径,内锥角内的对象被完全照亮,外锥角外的对象不被照亮,而两个角之间的对象被部分照亮。就像点光源一样,聚光灯通常用于在游戏场景的特定区域创建照明效果。聚光灯具有方向和角度衰减参数,为模拟游戏中特定区域的局部效果提供了更好的控制。
图 8-21
聚光灯及其参数
Note
在示意图中,如图 8-21 所示,为了清晰起见,光线方向通常由从光线位置向环境延伸的线条表示。这些线条通常用于说明目的,并不具有数学意义。这些示意图与解释照明计算的矢量图形成对比,如图 8-15 和 8-16 。在矢量图中,所有矢量总是指向远离被照亮的位置,并被假定为以 1 的量级归一化。
*行光和聚光灯项目
这个项目演示了如何将*行光和聚光灯集成到引擎中,以支持更广泛的照明效果。你可以在图 8-22 中看到项目运行的例子。这个项目的源代码位于chapter8/8.6.directional_and_spotlights
文件夹中。
图 8-22
运行*行光和聚光灯项目
该项目的控制措施如下:
- WASD 键:移动屏幕上的英雄角色
照明控制:
-
数字键 0、1、2、3 :选择对应的光源。
-
箭头键:移动当前选中的灯;请注意,这对*行光(灯光 1)没有影响。
-
按空格键的箭头键:改变当前选择的光的方向;请注意,这对点光源(光源 0)没有影响。
-
Z/X 键:增加/减少灯光 Z 位置;请注意,这对*行光(灯光 1)没有影响。
-
C/V 和 B/N 键:增加/减少所选光线的内外锥角;请注意,这些仅影响场景中的两个聚光灯(灯光 2 和 3)。
-
K/L 键:增加/减少所选光线的强度。
-
H 键:切换所选灯的开/关。
材料属性控制:
-
数字键 5 和 6 :选择左边的仆人和英雄
-
数字键 7、8、9 :选择Ka, K d , K s 所选角色(左仆从或英雄)的材料属性
-
E/R、T/Y、U/I 键:增加/减少所选材质属性的红色、绿色、蓝色通道
-
O/P 键:增加/减少所选材料属性的亮度
该项目的目标如下:
-
了解另外两种光源类型:*行光和聚光灯
-
检查所有三种不同光源类型的照明结果
-
为了体验控制所有三种光类型的参数
-
在引擎和 GLSL 着色器中支持三种不同的灯光类型
在 GLSL 碎片着色器中支持新的灯光类型
与之前的项目一样,新功能的集成将从 GLSL 着色器开始。您必须修改 GLSL IllumShader
和LightShader
片段着色器,以支持这两种新的灯光类型。
修改 GLSL 照明片段着色器
回想一下IllumShader
基于点光源模拟 Phong 照明模型。这将扩展到支持两种新的光源类型。
-
首先编辑
illum_fs.glsl
并为三种光类型定义常数。注意,为了支持 GLSL 着色器和引擎之间的正确通信,这些常量必须具有与在light.js
文件中定义的相应枚举数据相同的值。 -
扩展光源
struct
以适应新的光源类型。*行光只需要一个Direction
变量,而聚光灯需要一个Direction
、内角和外角以及一个DropOff
变量。如接下来将详细描述的,代替实际的角度值,内角和外角的余弦被存储在该结构中以便于高效实现。DropOff
变量控制聚光灯内外角之间光线衰减的速度。LightType
变量标识在结构中表示的光的类型。
#define ePointLight 0
#define eDirectionalLight 1
#define eSpotLight 2
// ******** WARNING ******
// The above enumerated values must be identical to
// Light.eLightType values defined in Light.js
// ******** WARNING ******
- 定义一个
AngularDropOff()
函数来计算聚光灯的角度衰减:
struct Light {
vec3 Position; // in pixel space!
vec3 Direction; // Light direction
vec4 Color;
float Near;
float Far;
float CosInner; // Cosine of inner cone angle for spotlight
float CosOuter; // Cosine of outer cone angle for spotlight
float Intensity;
float DropOff; // for spotlight
bool IsOn;
int LightType; // One of ePoint, eDirectional, or eSpot
};
float AngularDropOff(Light lgt, vec3 lgtDir, vec3 L) {
float strength = 0.0;
float cosL = dot(lgtDir, L);
float num = cosL - lgt.CosOuter;
if (num > 0.0) {
if (cosL > lgt.CosInner)
strength = 1.0;
else {
float denom = lgt.CosInner - lgt.CosOuter;
strength = smoothstep(0.0, 1.0, pow(num/denom, lgt.DropOff));
}
}
return strength;
}
参数lgt
是Light struct
中的一个聚光灯,lgtDir
是聚光灯的方向(或Light.Direction
归一化),而L
是当前被照明位置的光线矢量。注意,由于归一化矢量的点积是矢量之间角度的余弦,所以用相应的余弦值来表示所有的角位移并根据角位移的余弦值进行计算是很方便的。图 8-23 显示了角衰减计算中涉及的参数。
Note
lgtDir
是聚光灯的方向,而光矢量 L 是从被照亮的位置到聚光灯位置的矢量。
图 8-23
计算聚光灯的角度衰减
Note
以下代码基于角位移的余弦值。一定要记住,给定两个角度 α 和 β ,其中两者都在 0 到 180 度之间,如果 α > β ,那么,cos α < cos β 。
-
在将结果组合成一种颜色之前,修改
ShadedResults()
函数以处理光源类型的每一种单独情况: -
cosL
是L
与lgtDir
的点积;它记录当前被照亮位置的角位移。 -
num
变量存储了cosL
和cosOuter
之间的差值。负的num
意味着当前被照亮的位置在外锥之外,该位置将不被照亮,因此不需要进一步计算。 -
如果要照明的点在内锥内,
cosL
将大于lgt.CosInner
,将返回光的最大强度 1.0。 -
如果要照亮的点在内外锥角之间,使用
smoothstep()
功能计算光线的有效强度。
vec4 ShadedResult(Light lgt, vec3 N, vec4 textureMapColor) {
float aStrength = 1.0, dStrength = 1.0;
vec3 lgtDir = -normalize(lgt.Direction.xyz);
vec3 L; // light vector
float dist; // distance to light
if (lgt.LightType == eDirectionalLight) {
L = lgtDir;
} else {
L = lgt.Position.xyz - gl_FragCoord.xyz;
dist = length(L);
L = L / dist;
}
if (lgt.LightType == eSpotLight) {
// spotlight: do angle dropoff
aStrength = AngularDropOff(lgt, lgtDir, L);
}
if (lgt.LightType != eDirectionalLight) {
// both spot and point light has distance dropoff
dStrength = DistanceDropOff(lgt, dist);
}
vec4 diffuse = DiffuseResult(N, L, textureMapColor);
vec4 specular = SpecularResult(N, L);
vec4 result = aStrength * dStrength *
lgt.Intensity * lgt.Color * (diffuse + specular);
return result;
}
修改 GLSL 灯光片段着色器
现在可以修改 GLSL light_fs
片段着色器来支持两种新的灯光类型。所涉及的修改与对illum_fs
所做的更改非常相似,其中定义了对应于光类型的常数值,扩展了Light struct
以支持方向和聚光灯,并定义了角度和距离衰减函数以正确计算光的强度。具体实现请参考light_fs.glsl
源代码文件。
修改灯光类别
您必须扩展Light
类来支持两种新光源类型的参数:
-
编辑
src/engine/lights
文件夹中的light.js
,定义并导出不同灯类型的枚举数据类型。重要的是,枚举值对应于 GLSLillum_fs
和light_fs
着色器中定义的常量值。 -
修改构造函数,定义并初始化与*行光和聚光灯参数相对应的新变量。
// **** WARNING: The following enumerate values must be identical to
// the values of
//
// ePointLight, eDirectionalLight, eSpotLight
//
// defined in LightFS.glsl and IllumFS.glsl
const eLightType = Object.freeze({
ePointLight: 0,
eDirectionalLight: 1,
eSpotLight: 2
});
export { eLightType }
- 为新变量定义 get 和 set 访问器。这里没有列出这些函数的全部内容。详情请参考
light.js
源代码文件。
constructor() {
this.mColor = vec4.fromValues(1, 1, 1, 1); // light color
this.mPosition = vec3.fromValues(0, 0, 5); // light position in WC
this.mDirection = vec3.fromValues(0, 0, -1); // in WC
this.mNear = 5; // effective radius in WC
this.mFar = 10;
this.mInner = 0.1; // in radian
this.mOuter = 0.3;
this.mIntensity = 1;
this.mDropOff = 1; //
this.mLightType = eLightType.ePointLight;
this.mIsOn = true;
}
修改 ShaderLightAt 类
回想一下,ShaderLightAt
类负责从光源加载值到 GLSL 片段着色器。必须优化该对象,以支持与*行光和聚光灯对应的新光源参数。
-
编辑
shader_light_at.js
从light.js
导入eLightType
枚举类型: -
修改
_setShaderReferences()
功能,设置对新添加的灯光属性的引用:
import { eLightType } from "../lights/light.js";
- 修改
loadToShader()
函数,为*行光和聚光灯加载新添加的灯光变量。请注意,根据灯光类型,一些变量的值可能不会传递到 GLSL 着色器。例如,与角度衰减、内角和外角以及衰减相关的参数将仅针对聚光灯进行传递。
_setShaderReferences(aLightShader, index) {
let gl = glSys.get();
this.mColorRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Color");
this.mPosRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Position");
this.mDirRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Direction");
this.mNearRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Near");
this.mFarRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Far");
this.mInnerRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].CosInner");
this.mOuterRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].CosOuter");
this.mIntensityRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].Intensity");
this.mDropOffRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].DropOff");
this.mIsOnRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].IsOn");
this.mLightTypeRef = gl.getUniformLocation(
aLightShader, "uLights[" + index + "].LightType");
}
loadToShader(aCamera, aLight) {
let gl = glSys.get();
gl.uniform1i(this.mIsOnRef, aLight.isLightOn());
// Process a light only when it is switched on
if (aLight.isLightOn()) {
... identical to previous code ...
gl.uniform1f(this.mFarRef, f);
gl.uniform1f(this.mInnerRef, 0.0);
gl.uniform1f(this.mOuterRef, 0.0);
gl.uniform1f(this.mIntensityRef, aLight.getIntensity());
gl.uniform1f(this.mDropOffRef, 0);
gl.uniform1i(this.mLightTypeRef, aLight.getLightType());
// Point light does not need the direction
if (aLight.getLightType() === eLightType.ePointLight) {
gl.uniform3fv(this.mDirRef, vec3.fromValues(0, 0, 0));
} else {
// either spot or directional lights: must compute direction
let d = aCamera.wcDirToPixel(aLight.getDirection());
gl.uniform3fv(this.mDirRef, vec3.fromValues(d[0],d[1],d[2]));
if (aLight.getLightType() === eLightType.eSpotLight) {
gl.uniform1f(this.mInnerRef,
Math.cos(0.5 * aLight.getInner()));
gl.uniform1f(this.mOuterRef,
Math.cos(0.5 * aLight.getOuter()));
gl.uniform1f(this.mDropOffRef, aLight.getDropOff());
}
}
}
}
注意,对于mInnerRef
和mOuterRef
,实际计算并传递一半角度的余弦。内角和外角是聚光灯的总角展度,其中这些角的一半描述了与光线方向的角位移。由于这个原因,半角的余弦将实际用于计算。这种优化使 GLSL 片段着色器无需在每次调用时重新计算这些角度的余弦值。
修改相机变换类
*行光和聚光灯需要灯光方向,GLSL illum_fs
和light_fs
着色器希望在像素空间中指定该方向。编辑相机对象的camera_xform.js
文件,定义wcDirToPixel()
函数,将方向从 WC 转换到像素空间。
Camera.prototype.wcDirToPixel = function (d) { // d:vec3 direction in WC
// Convert the position to pixel space
let x = d[0] * this.mRenderCache.mWCToPixelRatio;
let y = d[1] * this.mRenderCache.mWCToPixelRatio;
let z = d[2];
return vec3.fromValues(x, y, z);
}
测试新的照明类型
MyGame
级的主要目标是测试和提供操纵新光源类型的功能。所涉及的修改是简单的;修改my_game_lights.js
以创建所有三种灯光类型,修改my_game_light_control.js
以支持同时按下箭头键和空格键时对所选灯光方向的操作。这里没有显示这些简单更改的实现。有关详细信息,请参考源代码文件。
观察
您可以运行项目并交互控制灯光来检查相应的效果。定义了四个光源,每个光源照亮场景中的所有对象。光源 0 是点光源,1 是*行光,2 和 3 是聚光灯。
通过键入 1 键选择*行光,可以检查*行光的效果。现在按住空格键,同时轮流按左/右或上/下键来改变方向灯的方向。您会注意到背景图像中 3D 几何形状的边界上的照明发生了剧烈变化,偶尔还会出现镜面反射的突出红色斑点。现在,键入 H 键关闭方向灯,观察整个场景变得更暗。没有任何种类的衰减,*行光可以用作照亮整个场景的有效工具。
再次键入 2 或 3 键选择一个聚光灯,方法是按住空格键,同时轮流按下左/右或上/下键来改变聚光灯的方向。使用聚光灯,您将观察到照明区域在一个圆形(当聚光灯垂直指向背景图像时)和不同的拉长椭圆之间摆动和改变形状。箭头键将移动被照亮的区域。尝试使用 C/V 和 B/N 键来增加/减少内外锥角。请注意,如果将内锥角设置为大于外锥角,照明区域的边界会变得清晰,聚光灯的照明效果会突然减弱。为了更清楚地观察聚光灯效果,可以考虑关闭方向灯“灯光 1”。
尝试不同的灯光设置,包括重叠灯光照明区域并将灯光强度、K 键和 L 键设置为负数。虽然在现实世界中不可能,但在游戏世界中,负强度灯光是完全有效的选择。
阴影模拟
阴影是光线被阻挡或遮挡的结果。作为一种日常现象,影子是你观察到但可能没有太多考虑的东西。然而,阴影在人类的视觉感知系统中起着至关重要的作用。例如,物体的阴影传达了相对大小、深度、距离、顺序等重要信息。在视频游戏中,适当的阴影模拟可以提高外观质量和逼真度。例如,您可以使用阴影来恰当地传达两个游戏对象之间的距离或英雄正在跳跃的高度。
可以通过确定环境中要照明的位置和每个光源位置之间的可见度来模拟阴影。如果某个位置被光源遮挡,或者从光源看不到该位置,则该位置相对于光源处于阴影中。在计算上,这是一个昂贵的操作,因为一般的可见性确定是一个 O(n) 操作,其中 n 是场景中对象的数量,并且必须对被照亮的每个像素执行该操作。从算法上来说,这是一个具有挑战性的问题,因为在照明计算期间,对于每个被照明的像素,可见性的解决方案必须在片段着色器中可用。
由于计算和算法的挑战,许多视频游戏不是根据物理世界来模拟阴影,而是基于专用的硬件资源仅为选定的对象*似或创建类似阴影的效果。在本节中,您将学习通过选择基于 WebGL 模板缓冲区的专用阴影投射器和接收器来*似阴影。
图 8-24 显示了一个游戏想要将Hero
物体的阴影投射到小精灵而不是背景上的例子。在这种情况下,背景对象将不参与阴影计算,因此不会接收到阴影。
图 8-24
英雄在仆从身上投射阴影,但不在背景上
为了正确模拟和渲染图 8-24 中的阴影,如图 8-25 所示,有三个重要的要素。
图 8-25
阴影模拟的三个参与元素:投射者、投射者几何体和接收者
-
影子施法者:这是产生影子的物体。在图 8-24 的例子中,
Hero
物体是阴影投射者。 -
阴影接收器:这是出现阴影的物体。在图 8-24 的例子中,
Minion
物体是阴影接收器。 -
阴影投射者几何:这是实际的阴影,换句话说,阴影接收器上的黑暗是因为光线的遮挡。在图 8-24 的例子中,出现在实际英雄物体后面的奴才身上的英雄黑暗印记是阴影施法者的几何图形。
给定三个参与元素,阴影模拟算法相当简单:计算阴影投射器几何体,照常渲染阴影接收器,将阴影投射器几何体渲染为接收器上的暗阴影投射器对象,最后,照常渲染阴影投射器。例如,为了渲染图 8-24 中的阴影,首先根据光源、Hero
对象(阴影投射者)和Minion
对象(阴影接收器)的位置计算黑暗英雄阴影投射者的几何体。之后,Minion
对象(阴影接收器)首先像往常一样渲染,然后像Hero
对象一样渲染阴影投射器几何体,颜色保持不变,最后Hero
对象(阴影投射器)像往常一样渲染。
请注意,阴影实际上是一种视觉效果,因为光能被阻挡,物体上的颜色看起来更暗。需要注意的重要一点是,当一个人观察阴影时,没有新的物体或几何图形参与其中。这与所描述的算法形成了鲜明的对比,在所描述的算法中,阴影是由阴影投射几何体(深色物体)模拟的。这个深色物体实际上并不存在于场景中。它是通过算法创建的,以*似光线被遮挡的视觉感知。这种创建和渲染额外的几何图形来模拟人类视觉感知的结果,虽然有趣,但也有其自身的挑战。
如图 8-26 所示,当阴影投射者的几何图形超出阴影接受者的界限时,阴影的幻觉就会消失。这种情况必须被适当地解决,以便阴影看起来是真实的。在图 8-24 中可以看到正确处理这种情况的例子;英雄头盔阴影的顶部超出了仆从的范围,所以没有画出来。
图 8-26
阴影投射者超出了阴影接受者的范围
幸运的是,WebGL 模板缓冲区是专门为解决这些情况而设计的。WebGL 模板缓冲区可以配置为开/关开关的 2D 阵列,其像素分辨率与 web 浏览器上显示的画布相同。使用这种配置,当启用模板缓冲区检查时,画布中可以绘制的像素将仅是那些对应的模板缓冲区像素被打开的像素。
图 8-27 用一个例子来说明该功能。在这个例子中,中间层是模板缓冲区,除了白色三角形区域中的像素被初始化为 on 之外,所有像素都被初始化为 off。当启用模板缓冲区检查时,顶层图像的绘制将导致画布(底层)中仅出现一个三角形区域。这个三角形区域由对应于模板缓冲区中三角形的 on 位置的像素形成。这样,模板缓冲区就像画布上的模板一样,只能在画布上绘制 on 区域。
图 8-27
WebGL 模具缓冲区
在 WebGL 模板缓冲区的支持下,现在可以通过识别所有阴影接收器和将每个接收器对应的阴影投射器分组来相应地指定阴影模拟。在图 8-24 的例子中,Hero
物体被分组为 minion 阴影接收器的阴影投射者。在这个例子中,背景物体要接收英雄的阴影,它必须被明确地标识为阴影接收器,并且Hero
物体必须作为阴影投射者与它组合在一起。请注意,如果没有明确地将 minion 对象分组为背景阴影接收器的阴影投射者,minion 将不会在背景上投射阴影。
如将在下面的实现讨论中详细描述的,阴影投射器和接收器的透明度以及投射光源的强度都会影响阴影的生成。重要的是要认识到,这种阴影模拟实际上是一种算法创造,其效果可以用来*似人类的感知。这个过程并没有描述阴影在现实世界中是如何形成的,完全有可能创造出不真实的戏剧效果,比如投射透明或者蓝色的阴影。
阴影模拟算法
阴影模拟和渲染算法现在可以概述如下:
Given a shadowReceiver
A: Draw the shadowReceiver to the canvas as usual
// Stencil op to enable the region for drawing on the shadowCaster
B1: Initialize all stencil buffer pixels to off
B2: Switch on stencil buffer pixels correspond to shadowReceiver
B3: Enable stencil buffer checking
// Compute shadowCaster geometries and draw them on shadowReceiver
C: For each shadowCaster of this shadowReceiver
D: For each shadow casting light source
D1: Compute the shadowCaster geometry
D2: Draw the shadowCaster geometry
列出的代码渲染阴影接收器和所有阴影投射器几何体,而不渲染实际的阴影投射器对象。B1、B2 和 B3 步骤打开对应于阴影接收器的模板缓冲像素。这类似于打开与图 8-27 中白色三角形相关的像素,启用可绘制的区域。步骤 C 和 D 的循环指出,必须为每个阴影投射光源计算单独的几何图形。到 D1 绘制阴影投射器几何图形的时间步时,包含阴影接收器印记的模板缓冲区和检查已启用,只有阴影接收器占用的像素才能在画布上绘制。
阴影着色器项目
这个项目演示了如何实现和集成阴影模拟算法到你的游戏引擎中。您可以在图 8-28 中看到项目运行的示例。这个项目的源代码位于chapter8/8.7.shadow_shaders
文件夹中。
图 8-28
运行阴影着色器项目
这个项目的控制与前一个项目相同:
- WASD 键:移动屏幕上的英雄角色
照明控制:
-
数字键 0、1、2、3 :选择对应的光源
-
箭头键:移动当前选中的灯;请注意,这对*行光(灯光 1)没有影响。
-
按空格键的箭头键:改变当前选择的光的方向;请注意,这对点光源(光源 0)没有影响。
-
Z/X 键:增加/减少灯光 Z 位置;请注意,这对*行光(灯光 1)没有影响。
-
C/V 和 B/N 键:增加/减少所选光线的内外锥角;请注意,这些仅影响场景中的两个聚光灯(灯光 2 和 3)。
-
K/L 键:增加/减少所选光线的强度。
-
H 键:切换所选灯的开/关。
材料属性控制:
-
数字键 5 和 6 :选择左边的仆人和英雄
-
数字键 7、8、9 :选择Ka, K d , K s 所选角色(左仆从或英雄)的材料属性
-
E/R、T/Y、U/I 键:增加/减少所选材质属性的红色、绿色、蓝色通道
-
O/P 键:增加/减少所选材料属性的亮度
该项目的目标如下:
-
理解阴影可以通过算法定义和渲染明确的几何图形来*似
-
欣赏 WebGL 模板缓冲区的基本操作
-
理解使用阴影投射器和接收器模拟阴影
-
基于 WebGL 模板缓冲区实现阴影模拟算法
创建 GLSL 片段着色器
需要两个独立的 GLSL 片段着色器来支持阴影的渲染,一个用于将阴影投射器几何体绘制到画布上,另一个用于将阴影接收器绘制到模板缓冲区中。
定义 GLSL 阴影投射片段着色器
GLSL shadow_caster_fs
片段着色器支持阴影投射几何体的绘制。参见图 8-25;阴影投射者的几何体是一个几何体,它假装是阴影投射者的阴影。该几何图形通常由引擎根据其与阴影投射者的距离进行缩放;离施法者越远,这个几何体就越大。
在片段着色器中,该几何体应该渲染为深色对象,以创建它是阴影的错觉。注意,每个阴影投射光源需要一个阴影投射几何体;因此,片段着色器仅支持一个光源。最后,该对象的黑暗程度取决于阴影投射光源的有效强度,因此,片段着色器必须定义功能来计算每种类型光源的强度。
-
在
src/glsl_shaders
文件夹中,创建一个文件shadow_caster_fs.glsl
。由于所有灯光类型都可以投射阴影,因此必须支持现有的灯光结构。现在,从light_fs
(未显示)复制Light struct
和光类型常量。这些数据结构和常量必须完全相同,以便引擎中相应的接口着色器可以重用现有的支持LightShader
的实用程序。唯一的区别是,由于必须为每个光源定义阴影投射几何体,在这种情况下,uLight
数组大小正好为 1。 -
定义阴影渲染的常数。
kMaxShadowOpacity
是不透明阴影应该达到的程度,而kLightStrengthCutOff
是一个截止阈值,强度小于该值的光线不会投射阴影。 -
为了正确支持来自三种不同光源类型
AngularDropOff()
和DistanceDropOff()
的阴影投射,函数也必须以与light_fs
(和illum_fs
)中完全相同的方式定义。你可以从light_fs
那里复制这些功能。请注意,由于uLight
阵列中只有一个光源,您可以从这些函数中移除光源参数,并在计算中直接引用uLight[0]
。这个参数替换是唯一需要的修改,因此这里没有显示代码。 -
请记住,阴影是因为光线遮挡而被观察到的,与光源的颜色无关。现在,修改
LightStrength()
函数来计算到达被照亮位置的光强度,而不是阴影颜色。
#define kMaxShadowOpacity 0.7 // max of shadow opacity
#define kLightStrengthCutOff 0.05 // any less will not cause shadow
float LightStrength() {
float aStrength = 1.0, dStrength = 1.0;
vec3 lgtDir = -normalize(uLights[0].Direction.xyz);
vec3 L; // light vector
float dist; // distance to light
if (uLights[0].LightType == eDirectionalLight) {
L = lgtDir;
} else {
L = uLights[0].Position.xyz - gl_FragCoord.xyz;
dist = length(L);
L = L / dist;
}
if (uLights[0].LightType == eSpotLight) {
// spotlight: do angle dropoff
aStrength = AngularDropOff(lgtDir, L);
}
if (uLights[0].LightType != eDirectionalLight) {
// both spot and point light has distance dropoff
dStrength = DistanceDropOff(dist);
}
float result = aStrength * dStrength;
return result;
}
将列出的LightStrength()
与light_fs
中的相同功能进行比较,主要有两个区别。首先,该函数不考虑灯光的颜色,而是返回一个浮点值,即光源的聚合强度。其次,由于uLight
数组的大小为 1,该函数在计算中移除了灯光参数并引用了uLight[0]
。
- 根据光源的强度,在
main()
函数中计算阴影的颜色。请注意,如果光线强度小于kLightStrengthCutOff
,将不会投射阴影,并且阴影的实际颜色并不完全是黑色或不透明。相反,它是程序员定义的uPixelColor
和来自纹理贴图的采样透明度的混合。
void main(void)
{
vec4 texFragColor = texture2D(uSampler, vTexCoord);
float lgtStrength = LightStrength();
if (lgtStrength < kLightStrengthCutOff)
discard;
vec3 shadowColor = lgtStrength * uPixelColor.rgb;
shadowColor *= uPixelColor.a * texFragColor.a;
gl_FragColor = vec4(shadowColor,
kMaxShadowOpacity * lgtStrength * texFragColor.a);
}
定义 GLSL 阴影接收器片段着色器
GLSL shadow_receiver_fs
片段着色器是用于将阴影接收器绘制到模板缓冲区中的着色器。请注意,模板缓冲区被配置为开/关缓冲区,其中gl_FragColor
中返回的任何值都会将相应的像素切换到开。因此,必须丢弃透明的接收器片段。
- 在
src/glsl_shaders
文件夹下,创建shadow_receiver_fs.glsl
,定义一个sampler2D
对象对阴影接收对象的颜色纹理贴图进行采样。此外,将常数kSufficientlyOpaque
定义为不透明度较小的片段将被视为透明并被丢弃的阈值。对应于丢弃片段的模板缓冲像素将保持关闭,因此将不能接收阴影几何图形。
// The object that fetches data from texture.
// Must be set outside the shader.
uniform sampler2D uSampler;
uniform vec4 uPixelColor;
// "varying" signifies that the texture coordinate will be
// interpolated and thus varies.
varying vec2 vTexCoord;
#define kSufficientlyOpaque 0.1
注意,为了便于引擎着色器类代码重用,不得更改变量名称uSampler
和vTexCoord
。这些对应于texture_fs.glsl
中定义的变量名,游戏引擎可以使用现有的SpriteShader
来方便信息加载到这个着色器中。
- 执行
main()
函数对阴影接收器对象的纹理进行采样,并测试不透明度阈值,以确定是否可以接收阴影:
void main(void)
{
vec4 texFragColor = texture2D(uSampler, vTexCoord);
if (texFragColor.a < kSufficientlyOpaque)
discard;
else
gl_FragColor = vec4(1, 1, 1, 1);
}
将 GLSL 阴影着色器连接到引擎
定义了两个新的 GLSL 着色器后,您可能会认为有必要定义两个对应的SimpleShader
/ Renderable
对来促进通信。情况并非如此,原因有二:
-
首先,只需要一个新的引擎着色器类型来支持
shadow_caster_fs
。使用shadow_receiver_fs
着色器中的策略变量命名,现有的SpriteShader
对象可以用于与shadow_receiver_fs
GLSL 片段着色器进行通信。 -
第二,不需要新的
Renderable
类。Renderable
类旨在支持使用相应的着色器绘制和操作游戏对象。这样,Renderable
的物体就被玩家看到了。在阴影着色器的情况下,shadow_caster_fs
绘制阴影投射几何体,shadow_receiver_fs
将阴影接收器几何体绘制到模板黄油中。请注意,这两个着色器都不支持绘制玩家可见的对象。由于这些原因,不需要相应的Renderable
对象。
创建阴影投射着色器
必须定义一个 JavaScript SimpleShader
子类,以便于从游戏引擎向 GLSL 着色器加载信息。在这种情况下,需要定义一个ShadowCasterShader
来与 GLSL shadow_caster_fs
片段着色器进行通信。
-
在
src/engine/shaders
文件夹下,创建shadow_caster_shader.js
;定义从SpriteShader
继承的ShadowCasterShader
类。因为每个阴影投射几何体都是由一个投射光源创建的,所以为着色器定义一个光源。 -
覆盖
activate()
函数以确保单个光源正确加载到着色器:
import SpriteShader from "./sprite_shader.js";
import ShaderLightAt from "./shader_light_at.js";
class ShadowCasterShader extends SpriteShader {
// constructor
constructor(vertexShaderPath, fragmentShaderPath) {
super(vertexShaderPath, fragmentShaderPath);
this.mLight = null; // The light that casts the shadow
this.mCamera = null;
// GLSL Shader must define uLights[1] (size of 1)!!
this.mShaderLight = new ShaderLightAt(this.mCompiledShader, 0);
}
... implementation to follow ...
}
export default ShadowCasterShader;
- 定义一个函数来设置该着色器的当前相机和光源:
// Overriding the activation of the shader for rendering
activate(pixelColor, trsMatrix, cameraMatrix) {
// first call the super class' activate
super.activate(pixelColor, trsMatrix, cameraMatrix);
this.mShaderLight.loadToShader(this.mCamera, this.mLight);
}
setCameraAndLights(c, l) {
this.mCamera = c;
this.mLight = l;
}
实例化默认阴影投射器和接收器着色器
必须创建引擎着色器的默认实例,以连接到新定义的 GLSL 着色器主减速器和接收器片段着色器:
-
修改
src/engine/core
文件夹中的shader_resources.js
导入ShadowCasterShader
,为两个新的阴影相关着色器定义常量和变量。 -
编辑
createShaders()
函数来定义引擎着色器,以连接到新的 GLSL 片段着色器。注意,两个引擎着色器都基于texture_vs
GLSL 顶点着色器。此外,如前所述,引擎SpriteShader
的一个新实例被创建来连接shadow_receiver_fs
GLSL 片段着色器。
import ShadowCasterShader from "../shaders/shadow_caster_shader.js";
let kShadowReceiverFS = "src/glsl_shaders/shadow_receiver_fs.glsl";
let mShadowReceiverShader = null;
let kShadowCasterFS = "src/glsl_shaders/shadow_caster_fs.glsl";
let mShadowCasterShader = null;
- 对
shader_resources.js
文件的其余修改都是例行的,包括定义访问器、加载和卸载 GLSL 源代码文件、清理着色器以及导出访问器。这里不包括这些的详细列表,因为您在许多场合看到过类似的变化。请参考实际实现的源代码文件。
function createShaders() {
... identical to previous code ...
mIllumShader = new IllumShader(kTextureVS, kIllumFS);
mShadowCasterShader = new ShadowCasterShader(
kTextureVS, kShadowCasterFS);
mShadowReceiverShader = new SpriteShader(
kTextureVS, kShadowReceiverFS);
}
配置和支持 WebGL 缓冲区
将 WebGL 模板缓冲区集成到游戏引擎中需要三个修改。首先,必须启用并正确配置 WebGL 模板缓冲区。其次,必须定义函数来支持带有模板缓冲区的绘图。第三,在每次拉伸循环之前,必须适当地清除缓冲器。
-
编辑
src/engine/core
文件夹中的gl.js
文件,以便在引擎初始化期间启用和配置 WebGL 模板缓冲区。在init()
函数中,添加 WebGL 初始化期间模板和深度缓冲区的分配和配置请求。请注意,深度缓冲区或 z 缓冲区也被分配和配置。这对于正确的阴影投射者支持是必要的,阴影投射者必须在接收者的前面,或者有更大的 z 深度以便在接收者上投射阴影。 -
继续使用
gl.js
;定义函数来开始、结束和禁止使用模板缓冲区绘图。记得导出这些新的模板缓冲支持函数。
function init(htmlCanvasID) {
... identical to previous code ...
mGL = mCanvas.getContext("webgl2",
{alpha: false, depth: true, stencil: true}) ||
mCanvas.getContext("experimental-webgl2",
{alpha: false, depth: true, stencil: true});
... identical to previous code ...
// make sure depth testing is enabled
mGL.enable(mGL.DEPTH_TEST);
mGL.depthFunc(mGL.LEQUAL);
}
- 在
clearCanvas()
功能中清除画布时,编辑src/engine
文件夹中的引擎访问文件index.js
,以清除模板和深度缓冲区:
function beginDrawToStencil(bit, mask) {
mGL.clear(mGL.STENCIL_BUFFER_BIT);
mGL.enable(mGL.STENCIL_TEST);
mGL.colorMask(false, false, false, false);
mGL.depthMask(false);
mGL.stencilFunc(mGL.NEVER, bit, mask);
mGL.stencilOp(mGL.REPLACE, mGL.KEEP, mGL.KEEP);
mGL.stencilMask(mask);
}
function endDrawToStencil(bit, mask) {
mGL.depthMask(mGL.TRUE);
mGL.stencilOp(mGL.KEEP, mGL.KEEP, mGL.KEEP);
mGL.stencilFunc(mGL.EQUAL, bit, mask);
mGL.colorMask(true, true, true, true);
}
function disableDrawToStencil() { mGL.disable(mGL.STENCIL_TEST); }
function clearCanvas(color) {
... identical to previous code ...
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT |
gl.DEPTH_BUFFER_BIT);
}
为游戏开发者定义影子支持
如定义ShadowCasterShader
时所述,Renderable
类不应被定义为与阴影投射器和接收器着色器配对,因为这将允许游戏开发者能够将算法创建的对象作为常规游戏对象来操作。相反,引入了ShadowCaster
和ShadowReceiver
类来允许游戏开发者创建阴影,而无需授权操作底层几何图形。
定义阴影施法者职业
与熟悉的Renderable
类层次不同,ShadowCaster
类被定义为封装隐式定义的阴影投射几何体的功能。回想一下图 8-25 ,阴影投射者的几何图形是基于阴影投射者Renderable
和阴影接收器Renderable
的位置,通过算法为每个阴影投射光源导出的。
为了支持在动画 sprite 元素上接收阴影,阴影接收器必须是SpriteRenderable
或它的子类。阴影投射Renderable
对象必须能够接收光源,因此必须属于LightRenderable
或其子类。一个ShadowCaster
对象维护对实际阴影投射和接收Renderable
对象的引用,并定义算法来计算和渲染由投射者LightRenderable
对象引用的每个光源的阴影投射几何图形。ShadowCaster
类的详细信息如下:
-
创建用于组织影子相关支持文件的
src/engine/shadows
文件夹和文件夹中的shadow_caster.js
文件。 -
定义
ShadowCaster
类和构造函数来初始化主销后倾角几何计算所需的实例变量和常数:
import * as shaderResources from "../core/shader_resources.js";
import SpriteRenderable from "../renderables/sprite_renderable.js";
import Transform from "../utils/transform.js";
import { eLightType } from "../lights/light.js";
// shadowCaster: GameObject referencing at least a LightRenderable
// shadowReceiver: GameObject referencing at least a SpriteRenderable
class ShadowCaster {
constructor(shadowCaster, shadowReceiver) {
this.mShadowCaster = shadowCaster;
this.mShadowReceiver = shadowReceiver;
this.mCasterShader = shaderResources.getShadowCasterShader();
this.mShadowColor = [0, 0, 0, 0.2];
this.mSaveXform = new Transform();
this.kCasterMaxScale = 3; // Max amount a caster will be scaled
this.kVerySmall = 0.001; //
this.kDistanceFudge = 0.01; // to avoid caster-receiver overlap
this.kReceiverDistanceFudge = 0.6;
// Factor to reduce the projected caster geometry size
}
setShadowColor(c) {
this.mShadowColor = c;
}
... implementation to follow ...
}
export default ShadowCaster;
mShadowCaster
是对至少有一个LightRenderable
的阴影投射者GameObject
的引用,mShadowReceiver
是至少有一个SpriteRenderable
渲染组件的GameObject
。正如将在下一步中详细描述的,mCasterShader
、mShadowColor
和mSaveXform
是支持阴影投射器几何图形渲染的变量。
- 实现
draw()
功能,为照亮mShadowCaster
的Renderable
物体的每个光源计算并绘制阴影投射几何体:
draw(aCamera) {
let casterRenderable = this.mShadowCaster.getRenderable();
// Step A: save caster xform/shader/color. Set caster to shadow color
this.mShadowCaster.getXform().cloneTo(this.mSaveXform);
let s = casterRenderable.swapShader(this.mCasterShader);
let c = casterRenderable.getColor();
casterRenderable.setColor(this.mShadowColor);
let l, lgt;
// Step B: loop through each light, if shadow casting is on
// compute the proper shadow offset
for (l = 0; l < casterRenderable.getNumLights(); l++) {
lgt = casterRenderable.getLightAt(l);
if (lgt.isLightOn() && lgt.isLightCastShadow()) {
// Step C: turn caster into caster geometry
// draws as SpriteRenderable
this.mSaveXform.cloneTo(this.mShadowCaster.getXform());
if (this._computeShadowGeometry(lgt)) {
this.mCasterShader.setCameraAndLights(aCamera, lgt);
SpriteRenderable.prototype.draw.call(
casterRenderable, aCamera);
}
}
}
// Step D: restore the original shadow caster
this.mSaveXform.cloneTo(this.mShadowCaster.getXform());
casterRenderable.swapShader(s);
casterRenderable.setColor(c);
}
casterRenderable
是实际投射阴影的Renderable
物体。以下是draw()
功能的四个主要步骤:
-
定义
_computeShadowGeometry()
函数,根据mShadowCaster
、mShadowReceiver
和投射光源计算阴影投射几何体。虽然在长度上有点吓人,但下面的功能在逻辑上可以分为四个区域。第一个区域声明并初始化变量。第二和第三个区域是if
语句的两种情况,处理方向和点/聚光灯的变换参数的计算。最后一个区域将计算的参数设置为主销后倾角几何体的变换,cxf
。 -
步骤 A 保存投射器
Renderable
状态、变换、着色器和颜色,并通过将其着色器设置为ShadowCasterShader
(mCasterShader
)及其颜色设置为阴影颜色,将其设置为阴影投射器几何体。 -
步骤 B 遍历照亮
casterRenderable
的所有光源,寻找打开并投射阴影的光源。 -
步骤 C,对于每个产生光的阴影,调用
_computeShadowGeometry()
函数来计算一个适当大小和位置的阴影投射几何体,并将其渲染为SpriteRenderable
。使用替换的ShadowCasterShader
和阴影颜色,渲染的几何体显示为实际casterRenderable
的阴影。 -
步骤 D 恢复
casterRenderable
的状态。
_computeShadowGeometry(aLight) {
// Region 1: declaring variables
let cxf = this.mShadowCaster.getXform();
let rxf = this.mShadowReceiver.getXform();
// vector from light to caster
let lgtToCaster = vec3.create();
let lgtToReceiverZ;
let receiverToCasterZ;
let distToCaster, distToReceiver; // along the lgtToCaster vector
let scale;
let offset = vec3.fromValues(0, 0, 0);
receiverToCasterZ = rxf.getZPos() - cxf.getZPos();
if (aLight.getLightType() === eLightType.eDirectionalLight) {
// Region 2: Processing a directional light
if (((Math.abs(aLight.getDirection())[2]) < this.kVerySmall) ||
((receiverToCasterZ * (aLight.getDirection())[2]) < 0)) {
return false; // direction light casting side way or
// caster and receiver on different sides of light in Z
}
vec3.copy(lgtToCaster, aLight.getDirection());
vec3.normalize(lgtToCaster, lgtToCaster);
distToReceiver = Math.abs(receiverToCasterZ / lgtToCaster[2]);
// measured along lgtToCaster
scale = Math.abs(1 / lgtToCaster[2]);
} else {
// Region 3: Processing a point or spot light
vec3.sub(lgtToCaster, cxf.get3DPosition(), aLight.getPosition());
lgtToReceiverZ = rxf.getZPos() - (aLight.getPosition())[2];
if ((lgtToReceiverZ * lgtToCaster[2]) < 0) {
return false; // caster and receiver
// on different sides of light in Z
}
if ((Math.abs(lgtToReceiverZ) < this.kVerySmall) ||
((Math.abs(lgtToCaster[2]) < this.kVerySmall))) {
// almost the same Z, can't see shadow
return false;
}
distToCaster = vec3.length(lgtToCaster);
vec3.scale(lgtToCaster, lgtToCaster, 1 / distToCaster);
// normalize lgtToCaster
distToReceiver = Math.abs(receiverToCasterZ / lgtToCaster[2]);
// measured along lgtToCaster
scale = (distToCaster +
(distToReceiver * this.kReceiverDistanceFudge)) /
distToCaster;
}
vec3.scaleAndAdd(offset, cxf.get3DPosition(),
lgtToCaster, distToReceiver + this.kDistanceFudge);
// Region 4: Setting casterRenderable xform
cxf.setRotationInRad(cxf.getRotationInRad());
cxf.setPosition(offset[0], offset[1]);
cxf.setZPos(offset[2]);
cxf.setWidth(cxf.getWidth() * scale);
cxf.setHeight(cxf.getHeight() * scale);
return true;
}
aLight
参数是投射光源。这个函数的目标是通过使用aLight
将阴影投射器投射到阴影接收器上,来计算和设置阴影投射器的几何变换cxf
。如图 8-29 所示,投射主销后倾角几何尺寸有两种情况需要考虑。首先,对于定向光源,投影大小是一个常数。第二,对于一个点或聚光灯,投影的大小是到接收器的距离的函数。这是if
语句的两种情况,区域 2 和 3,详细信息如下:
图 8-29
计算阴影投射几何图形
-
区域 2 :根据*行光计算*行投影。该区域内的
if
声明是为了确保当光线方向*行于 xy *面时,或者当光线方向是从阴影接收器朝向阴影投射者时,不会计算出阴影。请注意,为了获得戏剧性的效果,阴影投射者的几何图形将被适度缩放。 -
区域 3 :从点或聚光灯位置计算投影。该区域内的两个
if
声明是为了确保阴影投射者和接收者在灯光位置的同一侧,并且为了保持数学稳定性,两者都不太靠*光源。 -
区域 4 :使用计算出的
distToReceiver
和scale
来设置阴影投射者或cxf
的变换。
对象是给游戏开发者定义和使用阴影的。因此,请记住更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
定义影子接收器类
回想图 8-25 ,ShadowReceiver
是施法者物体的影子会出现的物体。如图 8-26 所示,ShadowReceiver
必须将其自身绘制到模板缓冲区中,以确保阴影投射几何体只出现在被ShadowReceiver
对象占据的像素上。
-
在
src/engine/shadows
文件夹中创建一个新文件shadow_receiver.js
;定义ShadowReceiver
类。在构造函数中,初始化接收阴影所需的常量和变量。如前所述,mReceiver
是一个至少带有SpriteRenderable
参考的GameObject
,并且是阴影的实际接收方。注意mShadowCaster
是一个由ShadowCaster
对象组成的数组。这些物体会在mReceiver
上投射阴影。 -
定义
addShadowCaster()
函数来添加一个游戏对象作为这个接收者的阴影投射者:
import * as shaderResources from "../core/shader_resources.js";
import ShadowCaster from "./shadow_caster.js";
import * as glSys from "../core/gl.js";
class ShadowReceiver {
constructor(theReceiverObject) {
this.kShadowStencilBit = 0x01; // stencil bit for shadow
this.kShadowStencilMask = 0xFF; // The stencil mask
this.mReceiverShader = shaderResources.getShadowReceiverShader();
this.mReceiver = theReceiverObject;
// To support shadow drawing
this.mShadowCaster = []; // array of ShadowCasters
}
... implementation to follow ...
}
export default ShadowReceiver;
- 定义
draw()
函数来绘制接收器和所有阴影投射器的几何图形:
addShadowCaster(lgtRenderable) {
let c = new ShadowCaster(lgtRenderable, this.mReceiver);
this.mShadowCaster.push(c);
}
// for now, cannot remove shadow casters
draw(aCamera) {
let c;
// Step A: draw receiver as a regular renderable
this.mReceiver.draw(aCamera);
// Step B: draw receiver into stencil to enable corresponding pixels
glSys.beginDrawToStencil(this.kShadowStencilBit,
this.kShadowStencilMask);
// Step B1: swap receiver shader to a ShadowReceiverShader
let s = this.mReceiver.getRenderable().swapShader(
this.mReceiverShader);
// Step B2: draw the receiver again to the stencil buffer
this.mReceiver.draw(aCamera);
this.mReceiver.getRenderable().swapShader(s);
glSys.endDrawToStencil(this.kShadowStencilBit,
this.kShadowStencilMask);
// Step C: draw shadow color to pixels with stencil switched on
for (c = 0; c < this.mShadowCaster.length; c++) {
this.mShadowCaster[c].draw(aCamera);
}
// switch off stencil checking
glSys.disableDrawToStencil();
}
这个函数实现了轮廓阴影模拟算法,并没有画出实际的阴影投射者。请注意,在步骤 A 和 B2 中,mReceiver
对象被绘制了两次。步骤 A,第一个draw()
函数,照常将mReceiver
渲染到画布上。步骤 B 为绘图启用模板缓冲器,其中所有随后的绘图将被引导至打开模板缓冲器像素。由于这个原因,步骤 B2 的draw()
函数使用ShadowReceiverShader
并打开模板缓冲区中对应于mReceiver
对象的所有像素。有了正确的模板缓冲设置,在步骤 C 中,对mShadowCaster
的draw()
函数调用将只在接收器覆盖的像素中绘制相应的阴影投射几何图形。
最后,再一次,ShadowReceiver
对象是为客户端游戏开发者创建阴影而设计的。因此,请记住更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
更新引擎支持
定义了新对象并配置了引擎后,必须修改一些现有的引擎类以支持新的影像操作。下面总结了所需的更改,但没有列出直接的更改。具体实现细节请参考源代码文件。
-
renderable.js
:ShadowCaster
和ShadowReceiver
对象都需要交换着色器的能力来渲染用于阴影模拟目的的对象。这个swapShader()
功能最好在Renderable
层级的根中实现。 -
light.js
:Light
源现在定义了mCastShadow
,一个布尔变量,以及相关的 getter 和 setter,指示灯光是否应该投射阴影。 -
camera_main.js
:现在Camera
WC 中心必须位于 z 距离处。为此定义了一个kCameraZ
常数,并在setViewAndCameraMatrix()
函数的mCameraMatrix
计算中使用。 -
transform.js
:必须修改Transform
类以支持cloneTo()
和 z 深度值的操作。
测试阴影算法
测试阴影模拟有两个重要方面。首先,您必须了解如何根据实现来编程和创建阴影效果。第二,你必须验证Renderable
物体可以作为阴影投射者和接收者。除了阴影设置和绘制之外,MyGame
级测试用例与之前的项目类似。
设置阴影
设置阴影系统的正确方法是创建所有的ShadowCaster
对象,然后创建并添加到ShadowReceiver
对象。my_game_shadow.js
文件定义了_setupShadow()
函数来演示这一点。
MyGame.prototype._setupShadow = function () {
// mLgtMinion has a LightRenderable
this.mLgtMinionShadow = new engine.ShadowReceiver(this.mLgtMinion);
this.mLgtMinionShadow.addShadowCaster(this.mIllumHero);
this.mLgtMinionShadow.addShadowCaster(this.mLgtHero);
// mIllumMinion has a SpriteAnimateRenderable
this.mMinionShadow = new engine.ShadowReceiver(this.mIllumMinion);
this.mMinionShadow.addShadowCaster(this.mIllumHero);
this.mMinionShadow.addShadowCaster(this.mLgtHero);
this.mMinionShadow.addShadowCaster(this.mLgtMinion);
// mBg has a IllumRenderable
this.mBgShadow = new engine.ShadowReceiver(this.mBg);
this.mBgShadow.addShadowCaster(this.mLgtHero);
this.mBgShadow.addShadowCaster(this.mIllumMinion);
this.mBgShadow.addShadowCaster(this.mLgtMinion); }
在MyGame
结束时调用_setupShadow()
函数。当所有其他的GameObject
实例都被正确地创建和初始化时,init()
函数。这个函数演示了不同类型的Renderable
物体可以作为阴影接收器。
-
LightRenderable
:mLgtMinionShadow
是用mLgtMinon
作为接收者创建的,它引用了一个LightRenderable
对象。 -
IllumRenderable
:mBgShadow
和mMinionShadow
是用mBg
和mIllumMinion
作为接收者创建的,它们都引用IllumRenderable
对象。
请注意,为了观察对象上的阴影,必须创建一个显式对应的ShadowReceiver
,然后将ShadowCaster
对象显式添加到接收器。例如,mLgtMinionShadow
将mLgtMinion
对象定义为接收器,只有mIllumHero
和mLgtHero
会在该对象上投射阴影。最后,注意mLgtMinon
和mIllumMinion
都是阴影的接收者和投射者。
画阴影
在 2D 图形中,通过覆盖以前绘制的对象来绘制对象。因此,在绘制阴影投射器之前,绘制阴影接收器和阴影投射器的几何图形是很重要的。my_game_main.js
中的以下my_game.draw()
函数说明了对象的重要绘制顺序:
draw() {
// Clear the canvas
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray
// Set up the camera and draw
this.mCamera.setViewAndCameraMatrix();
// always draw shadow receivers first!
this.mBgShadow.draw(this.mCamera); // also draws the receiver object
this.mMinionShadow.draw(this.mCamera);
this.mLgtMinionShadow.draw(this.mCamera);
this.mBlock1.draw(this.mCamera);
this.mIllumHero.draw(this.mCamera);
this.mBlock2.draw(this.mCamera);
this.mLgtHero.draw(this.mCamera);
this.mMsg.draw(this.mCamera); // draw last
this.mMatMsg.draw(this.mCamera);
}
注意抽签顺序是很重要的。首先画出所有三个阴影接收器。此外,在三个接收器中,mBgShadow
对象是实际的背景,因此是第一个被绘制的。回想一下,在ShadowReceiver
类的定义中,draw()
函数也绘制了接收器对象。因此,不需要调用mLgtMinion
、mIllumMinion
和mBg
对象的draw()
函数。
其余的MyGame
关卡和之前的项目很大程度上是相似的,这里就不一一列举了。详情请参考源代码。
观察
现在,您可以运行项目并观察阴影。注意模板缓冲区的效果,其中来自mIllumHero
对象的阴影投射到了 minion 上,但没有投射到背景上。按下 WASD 键移动两个Hero
对象。观察阴影随着两个英雄物体移动时如何提供深度和距离线索。右边的mLgtHero
被四盏灯照亮,因此投射出许多阴影。尝试选择和操作每个灯光,例如移动或更改方向,或者打开/关闭灯光,以观察对阴影的影响。你甚至可以尝试将阴影的颜色(在shadow_caster.js
中)改变成某种戏剧性的颜色,比如亮蓝色[0,0,5,1],并观察现实世界中不可能存在的阴影。
摘要
这一章指导你为游戏引擎开发一个简单而完整的 Phong 照明模型。这些示例是按照 Phong 照明模型的三个术语组织的:环境光、漫反射和镜面反射。光源的覆盖范围被巧妙地混合在一起,以确保每个讨论的主题都能观察到适当的照明。
本章中关于环境照明的第一个例子介绍了交互控制和微调场景颜色的概念。以下两个关于光源的例子提出了这样一个概念,即照明,一种颜色处理的算法方法,可以在引擎基础结构中本地化和开发,以支持最终的 Phong 照明模型。漫反射和法线贴图的例子非常关键,因为它支持基于简单物理模型的照明计算和 3D 环境模拟。
Phong 照明模型和对每对象材质属性的需求在镜面反射示例中进行了介绍。Phong 照明模型的中间向量版本被实现以避免计算每个像素的光源反射向量。光源类型项目演示了如何通过模拟真实世界中的不同光源来实现微妙但重要的照明变化。最后,最后一个例子说明了精确的阴影计算是非常重要的,并介绍了一种*似算法。最终的阴影模拟,尽管从真实世界的角度来看不准确并且有局限性,但在美学上是有吸引力的,并且能够传达许多相同的重要视觉线索。
本书的前四章介绍了游戏引擎的基本基础和组件。第 5 、 6 和 7 章扩展了核心引擎功能,分别支持绘图、游戏对象行为和相机控制。本章补充了第五章,将引擎渲染高保真场景的能力提升到了一个新的水*。在接下来的三章中,这种互补的模式将会重复。第九章将介绍物理行为模拟,第十章将讨论粒子效果,第十一章将完成引擎开发,为相机提供更高级的支持,包括*铺和视差。
游戏设计注意事项
你在第七章的“游戏设计考虑”部分所做的工作是创建一个基本的良好的游戏机制,最终需要与游戏设计的其他元素相结合,以创建一些让玩家感到满意的东西。除了基本的游戏循环之外,你还需要考虑游戏的系统、设置和元游戏,以及它们将如何帮助决定你设计的关卡种类。当你开始定义场景时,你将开始探索视觉和听觉设计的想法。
与大多数视觉艺术一样,游戏在很大程度上依赖于有效地利用灯光来传达背景。发生在午夜墓地的恐怖游戏通常会使用非常不同的灯光模型和调色板,而不是专注于乐观、快乐主题的游戏。许多人认为照明主要适用于在 3D 引擎中创建的游戏,这些引擎能够模拟真实的光线和阴影,但是照明的概念也适用于大多数 2D 游戏环境;考虑 Playdead 工作室的 2D 侧滚*台游戏 Limbo 给出的例子,如图 8-30 所示。
图 8-30
Playdead 和双十一的 Limbo,这是一款 2D 的侧滚游戏,巧妙利用背景照明和明暗对比技术来传达紧张和恐怖。照明既可以通过编程生成,也可以由视觉艺术家设计到图像本身的调色板中,并且通常是两者的组合(图像版权 Playdead media 详情请见 www. playdead. com/ limbo
灯光除了设置情绪,也经常作为游戏循环的核心元素;一个明显的例子是,玩家可能在黑暗中用虚拟手电筒导航,但灯光也可以通过提供关于游戏环境的重要信息来间接支持游戏机制。红色脉冲灯通常指示危险区域,某些种类的绿色环境灯可能指示安全区域或有致命气体的区域,地图上的闪光灯可以帮助指引玩家到重要的位置,等等。
在简单全局环境项目中,你看到了彩色环境照明对游戏设置的影响。在这个项目中,主角在金属面板、管道和机械的背景前移动,这可能是一艘宇宙飞船的外部。环境光是红色的,可以产生脉冲——注意当强度设置为相对较低的 1.5 和设置为过饱和的 3.5 时对情绪的影响,想象两个值之间的脉冲可能会传递一个故事或增加张力。在简单灯光着色器项目中,一个灯光被附加到英雄角色上(在这种情况下是一个点光源),你可以想象英雄必须在环境中导航以收集对象来完成只有在被灯光照亮时才可见的关卡(或者可能激活只有在被照亮时才打开的对象)。
多种灯光项目展示了各种光源和颜色如何为环境增添可观的视觉趣味(有时也称为局部环境照明)。改变灯光的类型、强度和颜色值通常会使环境看起来更加生动和迷人,因为您在现实世界中遇到的灯光通常来自许多不同的来源。这一章中的其他项目都是为了增强游戏中的存在感;当您使用漫反射着色器,法线贴图,镜面反射,不同的灯光类型和阴影时,请考虑如何将这些技术的一部分或全部集成到关卡的视觉设计中,以使游戏对象和环境感觉更加生动有趣。
在你开始思考照明和其他设计元素如何增强游戏设置和视觉风格之前,让我们暂时回到第七章的“游戏设计注意事项”一节中的简单游戏机制项目,并考虑你可能如何考虑将照明添加到机制中以使拼图更具吸引力。图 8-31 从练习结束时的基本机械开始。
图 8-31
简单的游戏机械项目,没有照明。回想一下,玩家控制标有 P 的圆圈,并且必须以正确的顺序激活锁的三个部分中的每一个,以脱离障碍并获得奖励
对于简单游戏机制项目的下一阶段,你如何将光直接整合到游戏循环中,使其成为游戏性的一部分?和前面的练习一样,最小化复杂性,并限制自己一次只能对当前游戏循环进行一次添加或改进,将有助于防止设计变得负担过重或过于复杂。通过考虑光线可能影响当前游戏屏幕的所有不同方式来开始这一阶段的练习。你可以选择一个黑暗的环境,玩家只能看到模糊的形状,除非用手电筒照亮一个区域,你可以使用彩色的光来改变被照亮的物体的可见颜色,或者你可以使用 X 射线或紫外线来显示肉眼看不到的物体信息。在这个例子中,你将在简单的序列机制中增加一个额外的维度:一束显示物体隐藏信息的光束,如图 8-32 所示。
图 8-32
增加了一个可移动的“手电筒”,可以发出特殊的光束
在这个游戏循环的第一次迭代中,设计要求玩家以正确的相对位置(顶部在顶部,中间在中间,底部在底部)和正确的顺序(顶部-中间-底部)激活锁的每个部分。交互设计为正确和错误的移动提供了一致的视觉反馈,使玩家能够理解游戏规则,通过一些实验,精明的玩家将推断出解锁障碍所需的正确顺序。现在想象一下,添加一个特殊的光束会如何将游戏带入一个新的方向:基于序列的基本概念,你可以创建一个越来越聪明的谜题,要求玩家首先在环境中发现手电筒,并在锁上取得任何进展之前将其作为一种工具进行实验。想象一下,即使没有手电筒,当英雄人物触摸形状时,玩家仍然可以直接激活形状(触发对象周围的高亮环,就像第一次迭代中的情况一样,如图 8-33 所示),但直接交互不足以激活锁的相应区域,除非手电筒首先揭示理解谜题所需的秘密线索。图 8-34 显示移动手电筒,用光束照亮其中一个物体,露出一个白点。
图 8-34
玩家将手电筒移至其中一个形状下,以揭示隐藏的线索(#1)
图 8-33
玩家能够直接激活物体,就像在第一次机械迭代中一样,但是锁的相应部分现在保持不活动
从游戏性来看,一个游戏环境中的任何物体都可以作为工具;作为一名设计师,你的工作是确保工具遵循一致的逻辑规则,玩家可以首先理解这些规则,然后预测性地应用这些规则来实现他们的目标。在这种情况下,有理由假设玩家会探索游戏环境,寻找工具或线索;如果手电筒是一个活动的物体,玩家将试图了解它在关卡中的作用。
我们的示例项目中的游戏循环随着手电筒的发展而发展,但是使用相同的基本排序原则和反馈隐喻。当玩家用手电筒显示物体上的秘密符号时,玩家可以通过仅在符号可见时激活物体来开始解锁序列。新的设计要求玩家以正确的顺序激活与锁的每个部分相对应的三个对象中的每一个,在这种情况下,从一个点到三个点;当一个部分中的所有对象都按顺序激活时,锁的那个部分将像在第一次迭代中一样亮起来。图 8-35 至 8-37 显示了使用手电筒光束的新顺序。
图 8-37
三个顶部部分中的第三个用手电筒的光束显露出来,并被游戏者激活(#6),从而激活锁的顶部部分(#7)。一旦锁的中部和下部被类似地激活,屏障被禁用,玩家可以要求奖励
图 8-36
玩家以正确的顺序激活三个顶部部分中的第二个(#4),进度条通过点亮另一个部分来确认正确的顺序(#5)。在这种实现中,玩家在激活具有一个点的对象之前不能激活具有两个点的对象(规则要求按照从一个点到三个点的顺序激活类似的对象)
图 8-35
随着手电筒显示隐藏的符号,玩家现在可以激活对象(#2),锁上的进度条(#3)指示玩家正在正确的轨道上完成一个序列
请注意,您对玩家从游戏循环的第一次迭代中收到的反馈进行了轻微的更改:您最初使用进度条来表示解锁障碍的总体进度,但您现在使用它来表示解锁锁的每个部分的总体进度。手电筒在通向关卡解决方案的因果链中引入了额外的一步,你现在已经采取了一步基本游戏循环,并在保持逻辑一致性和遵循玩家可以首先学习然后预测性地应用的一组规则的同时,做出了相当复杂和具有挑战性的东西。事实上,这个关卡已经开始成为许多冒险游戏中常见的谜题类型:游戏屏幕是一个充满了许多可移动物体的复杂环境;找到手电筒,并了解它的光束揭示了游戏世界中物体的隐藏信息,这将成为游戏设置本身的一部分。
重要的是要意识到,随着游戏复杂性的增加,交互模型的复杂性也会增加,为玩家提供适当的视听反馈以帮助他们理解自己的行为也变得更加重要(回想一下第一章,交互模型是玩家用来完成游戏任务的按键、按钮、操纵杆、触摸手势等的组合)。在当前的例子中,玩家现在不仅能控制英雄角色,还能控制手电筒。创建直观的交互模型是游戏设计的重要组成部分,通常比设计者意识到的要复杂得多;举一个例子,考虑将许多为鼠标和键盘设计的 PC 游戏移植到使用按钮和拇指棒的游戏控制台或仅使用触摸的移动设备的困难。开发团队经常在控制方案上投入数千小时的研究和测试,然而他们仍然经常错过目标;交互设计很难做好,经常需要数千小时的测试和改进,即使是很基本的动作,所以如果可能的话,你应该利用已有的惯例。当你设计交互时,记住两条黄金法则:第一,尽可能使用已知的和经过测试的模式,除非你有令人信服的理由要求玩家学习新的东西;第二,尽量减少玩家必须记住的独特动作的数量。几十年的用户测试清楚地表明,玩家不喜欢重新学习跨标题类似任务的基本组合键(例如,这就是为什么如此多的游戏在 WASD 上实现了移动标准化),类似的数据显示,当你要求玩家记住几个简单的独特按钮组合时,他们会变得不知所措。当然也有例外;例如,许多经典的街机格斗游戏使用几十种复杂的组合,但这些类型是针对特定类型的玩家的,他们认为掌握按钮组合是使体验变得有趣的基本组成部分。一般来说,如果不是游戏的有意组成部分,大多数玩家更喜欢保持交互的复杂性尽可能的精简和简单。
有许多方法可以控制多个对象。我们的手电筒最常见的模式可能是玩家“装备”它;也许如果玩家移动手电筒并点击鼠标左键,它就成为玩家的一个新技能,可以通过按下键盘上的某个键或点击鼠标右键来激活。或者,也许英雄角色可以使用 WASD 键在游戏屏幕上自由移动,而其他活动对象,如手电筒,首先通过鼠标左键选中,然后通过按住鼠标左键并将它们拖到位来移动。类似地,有多种方式向玩家提供上下文反馈,这将有助于教授谜题逻辑和规则(在这种情况下,我们使用锁周围的环作为进度条来确认玩家正在遵循正确的顺序)。当你尝试各种互动和反馈模型时,回顾一下其他游戏是如何处理类似任务的总是一个好主意,特别注意你认为特别有效的东西。
在下一章中,你将通过对游戏世界中的物体应用简单的物理学来研究你的游戏循环如何再次进化。
九、使用刚性形状模拟世界
完成本章后,您将能够
-
认识到模拟真实世界物理交互的巨大计算复杂性和成本
-
了解典型的游戏引擎物理组件基于简单的几何图形(如圆形和矩形)来*似物理交互
-
实现圆形和矩形几何图形的精确碰撞
-
辛欧拉积分的*似牛顿运动公式
-
基于数值稳定松弛方法解决互穿碰撞
-
计算并实现对碰撞的响应,类似于真实世界中刚体的行为
介绍
在游戏引擎中,模拟能量转移的功能通常被称为物理、物理系统、物理组件或物理引擎。游戏引擎物理组件在许多类型的游戏中扮演着重要的角色。游戏物理学的主题范围很广,包括但不限于刚体、软体、流体动力学和车辆物理学。可信的物理行为和游戏对象的交互已经成为许多现代 PC 和主机游戏以及最*的浏览器和智能手机游戏的关键元素,例如,球的弹跳、果冻块的摆动、湖面的波纹或汽车的打滑。对这些进行适当的模拟和现实的再现正成为普遍的期望。
不幸的是,真实世界的精确模拟可能涉及大量的细节,需要深入的学科知识,其中潜在的数学模型可能很复杂,相关的计算成本高得令人望而却步。例如,汽车的打滑取决于其速度、轮胎特性等。;湖面上的波纹取决于它的成因、湖面的大小等等。;果冻块的摆动取决于其密度、初始变形等。即使在非常简单的情况下,球的反弹也取决于它的材料、膨胀状态,理论上甚至取决于周围空气的粒子浓度。现代游戏引擎物理组件通过限制物理交互的类型和简化模拟计算的要求来解决这些复杂性。
物理引擎通常限制和模拟孤立类型的物理交互,并且不支持交互类型的一般组合。例如,球反弹(刚体)的正确模拟通常不会支持球碰撞和抖动果冻块(柔体),也不会精确模拟球与流体交互(流体动力学)所导致的涟漪效果。也就是说,通常刚体物理引擎不支持与柔体对象、流体或车辆的交互。同样,柔体物理引擎通常不允许与刚体或其他类型的物理对象进行交互。
此外,物理引擎通常*似一个大大简化的交互模型,同时主要关注于获得视觉上令人信服的结果。这种简化通常是以对物体几何形状和物理属性的假设的形式,并且将限制性交互规则应用于游戏世界中的选择性子集。例如,刚体物理引擎通常以下列方式简化对象的交互:
-
假设对象是质量均匀分布的连续几何形状,其中质心位于几何形状的中心
-
用简单的弹性和摩擦力来*似物体的材质属性
-
表明物体在交互过程中不会改变形状
-
将模拟限制在游戏场景中选定的对象子集
基于这一组假设,刚体物理模拟或刚体模拟能够捕捉和再现许多熟悉的现实世界物理交互,如物体反弹、下落和碰撞,例如,一个完全充气的弹跳球或一个简单的 Lego 积木从桌子上反弹并落在硬木地板上。只要在碰撞过程中不发生变形,这些类型的刚体物理相互作用可以可靠地实时模拟。
具有均匀分布质量且在交互过程中不改变形状的物体可以应用于游戏中的许多重要且有用的场景。一般来说,刚体物理引擎非常适合模拟相互接触的移动对象,如保龄球与球瓶碰撞或炮弹击中装甲板。但是,重要的是要认识到,对于给定的一组假设,刚体物理模拟不支持以下内容:
-
由多个几何部分组成的对象,例如箭头
-
具有重要材料属性的物体,例如磁性
-
质量分布不均匀的物体,例如棒球棒
-
在碰撞过程中改变形状的物体,例如橡胶球
在所有真实世界的物理对象交互类型中,刚体交互是最容易理解的,最容易获得*似解决方案,实现起来也最简单。本章仅关注刚体模拟。
章节概述
与照明功能类似,游戏引擎的物理组件也是游戏引擎设计、架构和实现的一个大而复杂的领域。记住这一点,你将开发刚体物理组件的基础上,所有以前的游戏引擎组件相同的方法。也就是分析、理解和实现各个步骤,以逐步实现组件的核心功能。在物理组件的情况下,包含刚体模拟的主要思想包括以下内容:
-
刚性形状和边界:定义
RigidShape
类,通过对独立和简单的几何图形而不是潜在复杂的Renderable
对象执行计算来支持优化模拟。这个主题将会被第一个专题,刚性形状和边界专题所涵盖。 -
碰撞检测:检查并执行数学运算以准确碰撞圆形和矩形
RigidShape
物体。一个重要的概念是,在数字世界中,刚性形状可以并且经常重叠,在CollisionInfo
对象中保留这种重叠事件的细节是至关重要的。碰撞检测所涵盖的主题将由三个独立的项目讨论,每个项目侧重于一个独特的碰撞相互作用。它们包括-
圆形之间的碰撞:圆形碰撞和 CollisionInfo 项目
-
矩形形状之间的碰撞:矩形碰撞项目
-
矩形和圆形之间的碰撞:矩形和圆形碰撞项目
-
-
运动:在一个以固定间隔更新的世界中,*似描述运动的积分。关于运动的主题将包含在刚体运动项目中。
-
碰撞物体的相互渗透:用数值稳定的解决方案来解决碰撞刚性形状之间的相互渗透,从而逐步纠正这种情况。本课题在碰撞位置修正项目中提出。
-
碰撞解决:用脉冲法模拟碰撞响应。冲量法将包含在两个项目中,首先是在碰撞解决项目中没有旋转的简单情况,最后是在碰撞角度解决项目中考虑旋转。
严格的形状和界限
模拟任意刚性形状之间的相互作用所涉及的计算在算法上是复杂的,并且计算成本很高。由于这些原因,刚体模拟通常基于一组有限的简单几何图形,例如,刚性圆和矩形。在典型的游戏引擎中,可以将这些简单的刚性形状附加到几何复杂的游戏对象上,以*似模拟这些游戏对象之间的物理交互,例如,在宇宙飞船上附加刚性圆,并在刚性圆上执行刚体物理模拟,以*似模拟宇宙飞船之间的物理交互。
根据现实世界的经验,您知道简单的刚性形状只有在发生物理接触时才能相互作用。从算法上来说,这种观察转化为检测刚性形状之间的碰撞。为了进行正确的模拟,必须测试每个图形与其他图形的碰撞。这样,碰撞测试就是一个 O(n 2 运算,其中 n 是参与模拟的形状数量。作为对这种高成本操作的优化,刚性形状通常由简单的几何图形(例如,圆)来界定,其中潜在的高成本碰撞计算仅在形状的边界重叠时调用。
刚性形状和边界项目
这个项目引入了具有简单循环边界的RigidShape
类,用于碰撞优化。定义的RigidShape
类将被集成到游戏引擎中,其中每个GameObject
对象都将引用一个Renderable
和一个RigidShape
对象。将绘制Renderable
对象,向玩家展示视觉上令人愉悦的游戏元素,而RigidShape
将在*似GameObject
对象行为的刚性形状模拟中进行处理。你可以在图 9-1 中看到这个项目运行的例子。该项目的源代码在chapter9/9.1.rigid_shapes_and_bounds
中定义。
图 9-1
运行刚性形状和边界项目
该项目的控制措施如下:
-
行为控制:
- G 键:随机创建一个新的刚性圆或矩形
-
绘制控件:
-
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。
-
该项目的目标如下:
-
定义
RigidShape
类并与GameObject
集成 -
证明一个
RigidShape
代表同一GameObject
上的一个相应的Renderable
几何图形 -
为建立刚体物理模拟器奠定基础
-
定义测试物理组件的初始场景
除了系统font
文件夹,您还可以在assets
文件夹中找到以下外部资源文件:
-
minion_sprite.png
是为宠臣和英雄对象准备的。 -
platform.png
和wall.png
是测试场景中的水*和垂直边界对象。 -
target.png
显示在当前选择的对象上。
设置实施支持
您将通过首先设置实现支持来开始构建这个项目。首先,用新文件夹组织引擎源代码结构,以应对复杂性的增加。其次,定义用于可视化和正确性验证的调试工具。第三,扩展库对旋转刚性形状的支持。
组织引擎源代码
在新组件的预期中,在src/engine
文件夹中,创建components
文件夹,并将input.js
组件源代码文件移动到该文件夹中。这个文件夹将包含物理和其他组件的源代码,这些将在后面的章节中介绍。您必须编辑camera_input.js
、loop.js
和index.js
来更新input.js
的源代码文件位置变化。
支持调试绘图
值得注意的是,只有一个Renderable
对象,通常由一个GameObject
引用,在游戏世界中实际上是可见的。刚性的形状在游戏世界中实际上是不存在的;它们被定义为*似模拟相应的Renderable
物体的物理相互作用。为了支持正确的调试和正确性验证,能够绘制和可视化刚性形状是很重要的。
-
在
src/core
文件夹中,创建debug_draw.js
,从LineRenderable
导入,定义支持的常量和变量,用于绘制简单形状为线段: -
定义
init()
函数来初始化绘图对象。mUnitCirclePos
是单位圆圆周上的位置,mLine
变量是将用于绘图的线对象。
import LineRenderable from "../renderables/line_renderable.js";
let kDrawNumCircleSides = 16; // approx circumference as line segments
let mUnitCirclePos = [];
let mLine = null;
-
定义
drawLine()
、drawCrossMarker()
、drawRectangle()
、drawCircle()
函数,根据定义的mLine
对象绘制相应的形状。这些函数的源代码与物理模拟无关,因此没有显示。有关详细信息,请参考项目源代码文件夹。 -
记住导出定义的函数:
function init() {
mLine = new LineRenderable();
mLine.setPointSize(5); // make sure when shown, its visible
let deltaTheta = (Math.PI * 2.0) / kDrawNumCircleSides;
let theta = deltaTheta;
let i, x, y;
for (i = 1; i <= kDrawNumCircleSides; i++) {
let x = Math.cos(theta);
let y = Math.sin(theta);
mUnitCirclePos.push([x, y]);
theta = theta + deltaTheta;
}
}
export {
init,
drawLine, drawCrossMarker, drawCircle, drawRectangle
}
启动调试绘图功能
编辑loop.js
,从debug_draw.js
导入,在start()
完成所有异步加载承诺后,调用init()
函数:
import * as debugDraw from "./debug_draw.js";
... identical to previous code ...
async function start(scene) {
... identical to previous code ...
// Wait for any async requests before game-load
await map.waitOnPromises();
// With all resources loaded, it is now possible to initialize
// system internal functions that depend on shaders, etc.
debugDraw.init(); // drawing support for rigid shapes, etc.
... identical to previous code ...
}
Note
创建所有着色器后,初始化调试绘图的有效替代方法是在shader_resources
模块的createShaders()
函数中。然而,从shader_resources.js
中的debug_draw.js
导入会创建一个循环导入:debug_draw
从LineRenderable
导入,试图从shader_resources
导入。
更新总帐矩阵库
gl-matrix
库通过vec2
加法支持顶点*移,通过vec2
标量乘法支持顶点缩放,但不支持顶点旋转。编辑src/lib/gl-matrix.js
文件,定义vec2.rotateWRT()
函数,支持angle
相对于ref
位置旋转顶点位置pt
。按照gl-matrix
的约定,函数的第一个参数out
返回运算结果。
vec2.rotateWRT = function(out, pt, angle, ref) {
var r=[];
vec2.subtract(r, pt, ref);
vec2.rotate(r, r, angle);
vec2.add(r, r, ref);
out[0] = r[0];
out[1] = r[1];
return r;
};
定义 RigidShape 基类
现在,您已经准备好将RigidShape
定义为矩形和圆形刚性形状的基类。该基类将封装这两个形状共有的所有功能。
-
首先在
src/engine
中创建一个新的子文件夹rigid_shapes
。在这个文件夹中,创建rigid_shape.js
,从debug_draw
导入,定义绘图颜色和RigidShape
类。 -
定义构造函数以包含所有子类共享的实例变量。
xf
参数通常是对由这个RigidShape
表示的Renderable
的Transform
的引用。mType
变量将由子类初始化,以区分形状类型,例如圆形和矩形。mBoundRadius
是碰撞优化的圆形边界的半径,mDrawBounds
表示是否应该绘制圆形边界。
import * as debugDraw from "../core/debug_draw.js";
let kShapeColor = [0, 0, 0, 1];
let kBoundColor = [1, 1, 1, 1];
class RigidShape {
... implementation to follow ...
}
export default RigidShape;
- 为实例变量定义适当的 getter 和 setter 函数:
constructor(xf) {
this.mXform = xf;
this.mType = "";
this.mBoundRadius = 0;
this.mDrawBounds = false;
}
- 定义
boundTest()
函数来确定两个形状的圆形边界是否重叠。如图 9-2 所示,两个圆之间的碰撞可以通过比较两个半径之和rSum
和圆心之间的距离dist
来确定。同样,这是一个相对高效的操作,旨在进行两个形状之间代价更高的精确碰撞计算。
getType() { return this.mType; }
getCenter() { return this.mXform.getPosition(); }
getBoundRadius() { return this.mBoundRadius; }
toggleDrawBound() { this.mDrawBounds = !this.mDrawBounds; }
setBoundRadius(r) { this.mBoundRadius = r; }
setTransform(xf) { this.mXform = xf; }
setPosition(x, y) { this.mXform.setPosition(x, y); }
adjustPositionBy(v, delta) {
let p = this.mXform.getPosition();
vec2.scaleAndAdd(p, p, v, delta);
}
_shapeColor() { return kShapeColor; }
_boundColor() { return kBoundColor; }
图 9-2
圆形碰撞检测:(a)无碰撞。(b)检测到碰撞
boundTest(otherShape) {
let vFrom1to2 = [0, 0];
vec2.subtract(vFrom1to2, otherShape.mXform.getPosition(),
this.mXform.getPosition());
let rSum = this.mBoundRadius + otherShape.mBoundRadius;
let dist = vec2.length(vFrom1to2);
if (dist > rSum) {
// not overlapping
return false;
}
return true;
}
- 定义
update()
和draw()
功能。目前,update()
是空的。启用后,draw()
功能会绘制圆形边界,并在边界中心绘制一个“X”标记。
update() { // nothing for now }
draw(aCamera) {
if (!this.mDrawBounds)
return;
debugDraw.drawCircle(aCamera, this.mXform.getPosition(),
this.mBoundRadius, this._boundColor());
debugDraw.drawCrossMarker(aCamera, this.mXform.getPosition(),
this.mBoundRadius * 0.2, this._boundColor());
}
定义 RigidRectangle 类
Renderable
对象根据应用于单位正方形的Transform
操作符对形状的几何信息进行编码。例如,旋转的矩形被编码为缩放和旋转的单位正方形。正如您所经历的,这种表示(其中单位正方形的顶点保持不变)以及来自 GLSL 顶点着色器的矩阵变换支持对于支持变换形状的绘制是有效且高效的。
RigidShape
s 是为交互设计的Renderable
对象,其中底层表示必须支持大量的数学计算。在这种情况下,显式表示和更新基础几何形状的顶点会更有效。例如,可以明确地计算和存储矩形的顶点位置,而不是缩放和旋转的正方形。这样,实际的顶点位置总是很容易用于数学计算。由于这个原因,RigidRectangle
将明确定义和维护矩形的顶点。
定义了刚性形状的抽象基类之后,现在可以创建第一个具体的刚性形状,即RigidRectangle
类。考虑到复杂的碰撞函数,实现源代码将被分成多个文件。现在,创建rigid_rectangle.js
作为访问文件,并从将实现核心RigidRectangle
功能的rigid_rectangle_main.js
导入。
-
在
src/rigid_shapes
文件夹中,创建rigid_rectangle.js
以从rigid_rectangle_main.js
导入并导出RigidRectangle
类。这是RigidRectangle
类访问文件,这个类的用户应该从这里导入。 -
现在,在
src/rigid_shapes
文件夹中创建rigid_rectangle_main.js
来导入RigidShape
和debugDraw
,并将RigidRectangle
定义为RigidShape
的子类。
import RigidRectangle from "./rigid_rectangle_main.js";
export default RigidRectangle;
- 定义构造函数初始化矩形维度,
mWidth
bymHeight
,和mType
。重要的是要认识到刚性矩形的顶点位置是由mXform
引用的Transform
控制的。相反,宽度和高度尺寸由mWidth
和mHeight
独立定义。这种尺寸分离允许设计者确定RigidRectangle
应该多紧地包裹相应的Renderable
。注意,形状的实际顶点和面法线是在setVertices()
和computeFaceNormals()
函数中计算的。面法线的定义将在以下步骤中详述:
import RigidShape from "./rigid_shape.js";
import * as debugDraw from "../core/debug_draw.js";
class RigidRectangle extends RigidShape {
... implementation to follow ...
}
export default RigidRectangle
;
- 定义
setVertices()
函数,根据mXform
定义的尺寸设置顶点位置。如图 9-3 所示,矩形上的顶点定义为索引 0 为左上角,1 为右上角,2 为右下角,索引 3 对应左下角的顶点位置。
constructor(xf, width, height) {
super(xf);
this.mType = "RigidRectangle";
this.mWidth = width;
this.mHeight = height;
this.mBoundRadius = 0;
this.mVertex = [];
this.mFaceNormal = [];
this.setVertices();
this.computeFaceNormals();
}
图 9-3
矩形的顶点和面法线
setVertices() {
this.mBoundRadius = Math.sqrt(this.mWidth * this.mWidth +
this.mHeight * this.mHeight) / 2;
let center = this.mXform.getPosition();
let hw = this.mWidth / 2;
let hh = this.mHeight / 2;
// 0--TopLeft;1--TopRight;2--BottomRight;3--BottomLeft
this.mVertex[0] = vec2.fromValues(center[0] - hw, center[1] - hh);
this.mVertex[1] = vec2.fromValues(center[0] + hw, center[1] - hh);
this.mVertex[2] = vec2.fromValues(center[0] + hw, center[1] + hh);
this.mVertex[3] = vec2.fromValues(center[0] - hw, center[1] + hh);
}
-
定义
computeFaceNormals()
功能。图 9-3 显示了矩形的面法线是垂直于边并指向远离矩形中心的向量。此外,请注意面法线和相应顶点的索引之间的关系。面法线索引 0 指向与从顶点 2 到 1 的向量相同的方向。该方向垂直于由顶点 0 和 1 形成的边。这样,索引为 0 的面法线垂直于第一条边,依此类推。请注意,面法线向量的长度被归一化为 1。面法向量将在以后用于确定碰撞。 -
定义尺寸和位置操作功能。在所有情况下,顶点和面法线都必须重新计算(
rotateVertices()
调用computeFaceNormals()
),确保顶点位置和mXform
的状态一致是至关重要的。
computeFaceNormals() {
// 0--Top;1--Right;2--Bottom;3--Left
// mFaceNormal is normal of face toward outside of rectangle
for (let i = 0; i < 4; i++) {
let v = (i + 1) % 4;
let nv = (i + 2) % 4;
this.mFaceNormal[i] = vec2.clone(this.mVertex[v]);
vec2.subtract(this.mFaceNormal[i],
this.mFaceNormal[i], this.mVertex[nv]);
vec2.normalize(this.mFaceNormal[i], this.mFaceNormal[i]);
}
}
- 现在,定义
draw()
函数将矩形的边画成线段,定义update()
函数更新矩形的顶点。顶点和面法线必须重新计算,因为,正如你可能从RigidShape
基类构造函数的讨论中回忆的那样,mXfrom
是对Renderable
对象的Transform
的引用;游戏可能操纵了Transform
的位置或旋转。为了确保RigidRectangle
始终反映潜在的Transform
变化,顶点和面法线必须在每次更新时重新计算。
incShapeSizeBy(dt) {
this.mHeight += dt;
this.mWidth += dt;
this.setVertices();
this.rotateVertices();
}
setPosition(x, y) {
super.setPosition(x, y);
this.setVertices();
this.rotateVertices();
}
adjustPositionBy(v, delta) {
super.adjustPositionBy(v, delta);
this.setVertices();
this.rotateVertices();
}
setTransform(xf) {
super.setTransform(xf);
this.setVertices();
this.rotateVertices();
}
rotateVertices() {
let center = this.mXform.getPosition();
let r = this.mXform.getRotationInRad();
for (let i = 0; i < 4; i++) {
vec2.rotateWRT(this.mVertex[i], this.mVertex[i], r, center);
}
this.computeFaceNormals();
}
draw(aCamera) {
super.draw(aCamera); // the cross marker at the center
debugDraw.drawRectangle(aCamera, this.mVertex, this._shapeColor());
}
update() {
super.update();
this.setVertices();
this.rotateVertices();
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
定义 RigidCircle 类
您现在可以用与RigidRectangle
相似的整体结构实现RigidCircle
类:
-
在
src/rigid_shapes
文件夹中,创建rigid_circle.js
以从rigid_circle_main.js
导入并导出RigidCircle
类。这是RigidCircle
类访问文件,这个类的用户应该从这里导入。 -
现在,在
src/rigid_shapes
文件夹中创建rigid_circle_main.js
来导入RigidShape
和debugDraw
,并将RigidCircle
定义为RigidShape
的子类:
import RigidCircle from "./rigid_circle_main.js";
export default RigidCircle;
- 定义初始化圆半径、
mRadius
和mType
的构造函数。类似于RigidRectangle
的尺寸,RigidCircle
的半径由mRadius
定义,与mXfrom
定义的尺寸无关。注意RigidCircle
、mRadius
和圆形边界mBoundRadius
的半径是分开定义的。这是为了确保未来的替代品能够将两者分开。
import RigidShape from "./rigid_shape.js";
import * as debugDraw from "../core/debug_draw.js";
class RigidCircle extends RigidShape {
... implementation to follow ...
}
export default RigidCircle;
- 定义维度的 getter 和 setter:
constructor(xf, radius) {
super(xf);
this.mType = "RigidCircle";
this.mRadius = radius;
this.mBoundRadius = radius;
}
- 定义将圆绘制为沿圆周的线段集合的函数。为了正确地显示圆的旋转,从中心到旋转的垂直圆周位置画一条线。
getRadius() { return this.mRadius; }
incShapeSizeBy(dt) {
this.mRadius += dt;
this.mBoundRadius = this.mRadius;
}
draw(aCamera) {
let p = this.mXform.getPosition();
debugDraw.drawCircle(aCamera, p, this.mRadius,
this._shapeColor());
let u = [p[0], p[1] + this.mBoundRadius];
// angular motion
vec2.rotateWRT(u, u, this.mXform.getRotationInRad(), p);
debugDraw.drawLine(aCamera, p, u,
false, this._shapeColor()); // show rotation
super.draw(aCamera); // draw last to be on top
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
修改游戏对象类来整合 RightShape
回想一下第六章的讨论,GameObject
类被设计用来封装游戏场景中对象的视觉外观和行为。被引用的Renderable
对象定义了GameObject
的视觉外观。到目前为止,GameObject
的行为已经被定义和实现为GameObject
类的一部分,以特定的移动速度mSpeed
和简单的自主行为rotateObjPointTo()
的形式。现在,您可以用即将推出的系统物理组件支持来替换这些特殊参数。
-
编辑
GameObject.js
删除对速度的支持,mSpeed
,以及相应的 setter 和 getter 函数和rotateObjPointTo()
函数。通过本章其余部分的更改,游戏对象行为将得到刚体物理模拟的支持。确保不去管其他变量和函数;它们被定义为支持外观和检测纹理重叠,pixelTouches()
。 -
在构造函数中,定义新的实例变量来引用一个
RigidShape
并提供绘图选项: -
定义
mRigidBody
的 getter 和 setter 以及切换绘图选项的函数:
class GameObject {
constructor(renderable) {
this.mRenderComponent = renderable;
this.mVisible = true;
this.mCurrentFrontDir = vec2.fromValues(0, 1); // front direction
this.mRigidBody = null;
this.mDrawRenderable = true;
this.mDrawRigidShape = false;
}
... implementation to follow ...
}
- 细化
draw()
和update()
函数以尊重绘图选项,并将GameObject
行为更新委托给RigidShape
类:
getRigidBody() { return this.mRigidBody; }
setRigidBody(r) { this.mRigidBody = r; }
toggleDrawRenderable() { this.mDrawRenderable = !this.mDrawRenderable; }
toggleDrawRigidShape() { this.mDrawRigidShape = !this.mDrawRigidShape; }
- 编辑
game_object_set.js
文件来修改GameObjectSet
类,以支持整个集合的不同绘图选项的切换:
draw(aCamera) {
if (this.isVisible()) {
if (this.mDrawRenderable)
this.mRenderComponent.draw(aCamera);
if ((this.mRigidBody !== null) && (this.mDrawRigidShape))
this.mRigidBody.draw(aCamera);
}
}
update() {
// simple default behavior
if (this.mRigidBody !== null)
this.mRigidBody.update();
}
... identical to previous code ...
toggleDrawRenderable() {
let i;
for (i = 0; i < this.mSet.length; i++) {
this.mSet[i].toggleDrawRenderable();
}
}
toggleDrawRigidShape() {
let i;
for (i = 0; i < this.mSet.length; i++) {
this.mSet[i].toggleDrawRigidShape();
}
}
toggleDrawBound() {
let i;
for (i = 0; i < this.mSet.length; i++) {
let r = this.mSet[i].getRigidBody()
if (r !== null)
r.toggleDrawBound();
}
}
RigidShape 功能的测试
RigidShape
被设计为*似并代表一个Renderable
对象参与刚性形状模拟。因此,创建和测试RigidShape
类型的不同组合是必要的,包括圆形和矩形,以及Renderable
类型的所有组合,更具体地说,TextureRenderable
、SpriteRenderable
和SpriteAnimateRenderable
。这些组合的正确运行可以证明RigidShape
实现的正确性,并允许您直观地检查用简单的圆形和矩形*似Renderable
对象的适用性和局限性。
测试程序MyGame
的整体结构很大程度上类似于以前的项目,在这些项目中,源代码的细节可能会令人分心,因此这里不列出。相反,下面描述了被测试的对象以及这些对象如何满足指定的需求。和往常一样,源代码文件位于src/my_game
文件夹,支持对象类位于src/my_game/objects
文件夹。
测试即将发生的碰撞需要操纵每个物体的位置和旋转。在wasd_obj.js
中实现的WASDObj
类定义了GameObject
的 WASD 键移动和 Z/X 键旋转控制。在hero.js
中实现的WASDObj
的子类Hero
类是一个带有SpriteRenderable
和RigidRectangle
的GameObject
。Minion
类,也是minion.js
中WASDObj
的子类,是一个带有SpriteAnimateRenderable
的GameObject
,由RigidCircle
或RigidRectangle
包装。基于这些支持类,创建的Hero
和Minion
对象包含了Renderable
和RigidShape
类型的不同组合,允许您直观地检查用不同的RigidShape
表示复杂纹理的准确性。
游戏场景中的垂直和水*边界是由my_game_bounds.js
文件中定义的wallAt()
和platformAt()
函数创建的带有TextureRenderable
和RigidRectangle
的GameObject
实例。constructor
、init()
、draw()
、update()
等。在my_game_main.js
文件中定义了MyGame
的,其功能与之前的测试项目基本相同。
观察
您现在可以运行项目并观察创建的RigidShape
对象。注意,默认情况下,只绘制了RigidShape
对象。您可以通过按 T 键来切换Renderable
对象的图形来验证这一点。注意Renderable
对象的纹理是如何被相应的RigidShape
实例绑定的。您可以键入 R 键来关闭RigidShape
对象的绘制。通常,这是游戏玩家会观察到的,只有Renderable
而没有RigidShape
对象被绘制。由于本章的重点是刚性形状及其相互作用的模拟,默认显示的是RigidShape
而不是Renderable
对象。
现在再次键入 T 和 R 键,切换回RigidShape
对象的绘制。B 键显示形状的圆形边界。在接下来的几节中将要讨论的更精确和更昂贵的碰撞计算只会在这些边界重叠时在对象之间发生。
你可以尝试使用 WASD 键移动当前选中的对象,默认情况下Hero
在中间。Z/X 和 Y/U 键允许您旋转和改变Hero
的尺寸。用 T 键切换纹理,以验证旋转和移动同时应用于Renderable
及其对应的RigidShape
,并且 Y/U 键仅改变RigidShape
的尺寸。这允许设计者控制用相应的RigidShape
包裹Renderable
的紧密程度。您可以键入左/右箭头键来选择和处理场景中的任何对象。最后,G 键用RigidCircle
或RigidRectangle
创建新的Minion
对象。
最后,请注意,您可以将任何选定的对象移动到任何位置,包括与另一个RigidShape
对象重叠。在现实世界中,刚性形状对象的重叠或相互渗透永远不会发生,而在模拟的数字世界中,这是一个必须解决的问题。验证了RigidShape
类的功能后,现在可以检查如何计算这些形状之间的碰撞。
冲突检出
为了模拟刚性形状的相互作用,您必须首先检测哪些形状彼此发生了物理接触,或者哪些形状发生了碰撞。一般来说,在处理刚性形状碰撞时,有两个重要问题需要解决:计算成本和形状重叠或相互渗透的情况。在下文中,宽相位法和窄相位法被解释为一种减轻计算成本的方法,并且引入碰撞信息来记录互穿条件,使得它们可以被解决。这一小节和接下来的两小节详细介绍了圆-圆、矩形-矩形和圆-矩形碰撞的碰撞检测算法和实现。
宽和窄相位方法
正如在介绍RigidShape
对象的圆形边界时所讨论的,一般来说,每个对象都必须测试是否与游戏场景中的其他对象发生碰撞。例如,如果要检测五个对象 A、B、C、D 和 E 之间的碰撞,则必须针对第一个对象 A、对象 B、C、D 和 E 执行四次检测计算。在计算出 A 和 B 的结果后,接下来必须针对对象 C、D 和 E 在第二个对象 B 之间执行三次碰撞检测;接着是第三个物体 C 的两次碰撞;最后,第四个对象 d 有一个,第五个对象 E 已经和其他四个对象进行了对比测试。这个测试过程虽然彻底,但也有缺点。如果没有专门的优化,你必须执行 O(n 2 运算来检测 n 个对象之间的冲突。
在刚性形状模拟中,需要涉及密集计算的详细碰撞检测算法。这是因为必须计算精确的结果,以支持有效的穿透解析和真实的碰撞响应模拟。宽相位方法通过利用物体的接*度来排除那些物理上彼此远离从而显然不可能碰撞的物体,从而优化了这种计算。这允许详细且计算密集的算法或窄相位方法被部署用于物理上彼此接*的对象。
一种流行的宽相位方法使用轴对齐边界框(AABBs)或边界圆来*似物体的接*度。如第六章所述,AABBs 非常适合逼*与主轴对齐的对象,但在对象旋转时有所限制。正如您在键入 B 键的情况下运行上一个项目时所观察到的,边界圆是以对象为中心并完全包围对象的圆。通过执行简单的边界框/圆相交计算,可以只关注具有重叠边界的对象,作为窄相位碰撞检测操作的候选对象。
还有其他广泛的阶段方法,这些方法使用空间结构(如统一网格或四叉树)或相关组(如边界碰撞器的层次)来组织对象。来自宽相位方法的结果通常被输入到中间相位和最后的窄相位碰撞检测方法中。每个阶段都缩小了最终碰撞计算的候选范围,并且每个后续阶段都越来越精确,越来越昂贵。
碰撞信息
除了报告物体是否发生碰撞,碰撞检测算法还应该计算并返回可用于解决和响应碰撞的信息。正如你在测试之前的项目时所观察到的,物体有可能在空间上重叠,或者互相渗透。由于现实世界中的刚性物体不能相互穿透,记录细节和解决RigidShape
重叠是至关重要的。
如图 9-4 所示,碰撞和穿插的基本信息包括碰撞深度、法线、起点和终点。碰撞深度是对象相互穿透的最小量,其中碰撞法线是测量碰撞深度的方向。起点和终点是为了方便将贯穿绘制为线段而定义的贯穿的起点和终点位置。始终正确的是,通过将碰撞对象沿碰撞法线移动碰撞深度大小或从起点到终点位置的距离,可以解决凸对象的任何穿插。
图 9-4
碰撞信息
圆环碰撞和碰撞信息项目
该项目构建了计算和处理基于圆之间碰撞的碰撞信息的基础设施。你可以在图 9-5 中看到这个项目运行的例子。该项目的源代码在chapter9/9.2.circle_collisions_and_colllision_info
中定义。
图 9-5
运行 CollisionInfo 和圆形碰撞项目
项目的控制与上一个项目相同,只是在绘图控制中增加了一个 C 键盘命令:
-
行为控制:
- G 键:随机创建一个新的刚性圆或矩形
-
绘制控件:
-
C 键:切换所有
CollisionInfo
的绘制 -
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象。
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。
-
该项目的目标如下:
-
为了理解宽相位碰撞检测的优点和缺点
-
来建立计算圈间碰撞的基础设施
-
通过
CollisionInfo
类定义和处理碰撞条件 -
理解并实现圆形碰撞检测算法
定义 CollisionInfo 类
必须定义一个新的类来记录RigidShape
穿插情况,如图 9-4 所示:
-
在
src/engine/rigid_shape
文件夹中,创建collision_info.js
文件,从debugDraw
导入,声明绘图颜色为洋红色,定义CollisionInfo
类: -
使用与图 9-4 中所示的实例变量相对应的实例变量定义构造函数,用于碰撞深度、法线以及开始和结束位置:
import * as debugDraw from "../core/debug_draw.js";
let kInfoColor = [1, 0, 1, 1]; // draw the info in magenta
class CollisionInfo {
... implementation to follow ...
}
export default CollisionInfo;
- 为变量定义 getter 和 setter:
constructor() {
this.mDepth = 0;
this.mNormal = vec2.fromValues(0, 0);
this.mStart = vec2.fromValues(0, 0);
this.mEnd = vec2.fromValues(0, 0);
}
- 创建一个函数来翻转碰撞法线的方向。该功能将用于确保法线始终指向正在进行碰撞测试的对象。
getDepth() { return this.mDepth; }
setDepth(s) { this.mDepth = s; }
getNormal() { return this.mNormal; }
setNormal(s) { this.mNormal = s; }
getStart() { return this.mStart; }
getEnd() { return this.mEnd; }
setInfo(d, n, s) {
this.mDepth = d;
this.mNormal[0] = n[0];
this.mNormal[1] = n[1];
this.mStart[0] = s[0];
this.mStart[1] = s[1];
vec2.scaleAndAdd(this.mEnd, s, n, d);
}
- 定义一个
draw()
函数,以洋红色显示开始、结束和碰撞法线:
changeDir() {
vec2.scale(this.mNormal, this.mNormal, -1);
let n = this.mStart;
this.mStart = this.mEnd;
this.mEnd = n;
}
draw(aCamera) {
debugDraw.drawLine(aCamera, this.mStart, this.mEnd, true, kInfoColor);
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
修改 RigidShape 类
必须更新类以支持冲突。由于抽象基础形状RigidShape
不包含实际的几何信息,实际的碰撞函数必须在 rectangle 和 circle 类中实现。
修改 RigidRectangle 类
为了可读性,冲突支持将在单独的源代码文件中实现,rigid_rectangle_collision.js
:
-
修改
rigid_rectangle.js
以从新的源代码文件导入: -
在
src/engine/rigid_shapes
文件夹中,创建rigid_rectangle_collision.js
文件,导入CollisionInfo
和RigidRectangle
,定义collisionTest()
函数总是返回碰撞失败状态。与RigidRectangle
形状的碰撞将总是失败,直到下一个分段。
import RigidRectangle from "./rigid_rectangle_collision.js";
export default RigidRectangle;
- 记住为客户端导出扩展的
RigidRectangle
类:
RigidRectangle.prototype.collisionTest =
function (otherShape, collisionInfo) {
let status = false;
if (otherShape.mType === "RigidCircle") {
status = false;
} else {
status = false;
}
return status;
}
export default RigidRectangle;
修改 RigidCircle 类
修改RigidCircle
源代码文件的方式与RigidRectangle
完全相同:编辑rigid_circle.js
从rigid_circle_collision.js
导入。现在,您已经准备好实现圆-圆碰撞检测了。
-
在
src/engine/rigid_shape
文件夹中,创建rigid_circle_collision.js
文件,导入RigidCircle
,定义collisionTest()
函数,如果otherShape
不是RigidCircle
,则总是返回碰撞失败状态;否则,调用并返回collideCircCirc()
的状态。目前,一辆RigidCircle
还不知道如何与一辆RigidRectangle
相撞。 -
定义
collideCircCirc()
函数来检测两个圆之间的碰撞,并在检测到碰撞时计算相应的碰撞信息。碰撞检测有三种情况:没有碰撞(步骤 1),与位于不同位置的两个圆的中心碰撞(步骤 2),以及与位于完全相同位置的两个中心碰撞(步骤 3)。下面的代码显示了步骤 1,无冲突检测;注意,该代码也对应于图 9-2 所示的情况。
import RigidCircle from "./rigid_circle_main.js";
RigidCircle.prototype.collisionTest =
function (otherShape, collisionInfo) {
let status = false;
if (otherShape.mType === "RigidCircle") {
status = this.collideCircCirc(this, otherShape, collisionInfo);
} else {
status = false;
}
return status;
}
图 9-6
圆-圆碰撞的细节
- 当检测到碰撞时,如果两个圆心位于不同的位置(步骤 2),可以计算碰撞深度和法向,如图 9-6 所示。由于
c2
是另一个RigidShape
的参考,碰撞法线是一个从c1
指向c2
或与vFrom1to2
方向相同的向量。碰撞深度为rSum
与dist
之差,c1
的起始位置简单来说就是c2
-负mFrom1to2
方向距离c2
中心的半径距离。
RigidCircle.prototype.collideCircCirc= function (c1, c2, collisionInfo) {
let vFrom1to2 = [0, 0];
// Step 1: Determine if the circles overlap
vec2.subtract(vFrom1to2, c2.getCenter(), c1.getCenter());
let rSum = c1.mRadius + c2.mRadius;
let dist = vec2.length(vFrom1to2);
if (dist > Math.sqrt(rSum * rSum)) {
// not overlapping
return false;
}
... implementation of Steps 2 and 3 to follow ...
}
- 两个碰撞圆的最后一种情况是当两个圆心位于完全相同的位置时(步骤 3)。在这种情况下,碰撞法线被定义为负 y 方向,碰撞深度只是两个半径中较大的一个。
// Step 1: refer to previous step
if (dist !== 0) {
// Step 2: Colliding circle centers are at different positions
vec2.normalize(vFrom1to2, vFrom1to2);
let vToC2 = [0, 0];
vec2.scale(vToC2, vFrom1to2, -c2.mRadius);
vec2.add(vToC2, c2.getCenter(), vToC2);
collisionInfo.setInfo(rSum - dist, vFrom1to2, vToC2);
}
... implementation of Step 3 to follow ...
// Step 1: refer to previous step
if (dist !== 0) {
// Step 2: refer to previous step
} else {
let n = [0, -1];
// Step 3: Colliding circle centers are at exactly the same position
if (c1.mRadius > c2.mRadius) {
let pC1 = c1.getCenter();
let ptOnC1 = [pC1[0], pC1[1] + c1.mRadius];
collisionInfo.setInfo(rSum, n, ptOnC1);
} else {
let pC2 = c2.getCenter();
let ptOnC2 = [pC2[0], pC2[1]+ c2.mRadius];
collisionInfo.setInfo(rSum, n, ptOnC2);
}
}
定义物理组件
现在,您可以定义物理组件来触发碰撞检测计算:
-
在
src/engine/components
文件夹中,创建physics.js
文件,导入CollisionInfo
并声明变量以支持该文件本地的计算。 -
定义
collideShape()
函数来触发碰撞检测计算。注意在实际调用形状collisionTest()
之前的两个测试。首先,检查以确保两个形状实际上不是同一个对象。第二,调用宽相位boundTest()
方法来确定形状的接*度。注意,最后一个参数infoSet
在定义时将包含所有成功碰撞的所有CollisionInfo
对象。这被定义为支持可视化CollisionInfo
对象用于验证和调试目的。 -
定义支持游戏开发者的效用函数:
processSet()
在同一GameObjectSet
中的所有对象之间执行碰撞判定,processObjToSet()
在给定的GameObject
和GameObjectSet
的对象之间进行检查,processSetToSet()
在两个不同的GameObjectSet
中的所有对象之间进行检查
function collideShape(s1, s2, infoSet = null) {
let hasCollision = false;
if (s1 !== s2) {
if (s1.boundTest(s2)) {
hasCollision = s1.collisionTest(s2, mCInfo);
if (hasCollision) {
// make sure mCInfo is always from s1 towards s2
vec2.subtract(mS1toS2, s2.getCenter(), s1.getCenter());
if (vec2.dot(mS1toS2, mCInfo.getNormal()) < 0)
mCInfo.changeDir();
// for showing off collision mCInfo!
if (infoSet !== null) {
infoSet.push(mCInfo);
mCInfo = new CollisionInfo();
}
}
}
}
return hasCollision;
}
- 现在,导出所有已定义的功能:
// collide all objects in the GameObjectSet with themselves
function processSet(set, infoSet = null) {
let i = 0, j = 0;
let hasCollision = false;
for (i = 0; i < set.size(); i++) {
let s1 = set.getObjectAt(i).getRigidBody();
for (j = i + 1; j < set.size(); j++) {
let s2 = set.getObjectAt(j).getRigidBody();
hasCollision = collideShape(s1, s2, infoSet) || hasCollision;
}
}
return hasCollision;
}
// collide a given GameObject with a GameObjectSet
function processObjToSet(obj, set, infoSet = null) {
let j = 0;
let hasCollision = false;
let s1 = obj.getRigidBody();
for (j = 0; j < set.size(); j++) {
let s2 = set.getObjectAt(j).getRigidBody();
hasCollision = collideShape(s1, s2, infoSet) || hasCollision;
}
return hasCollision;
}
// collide between all objects in two different GameObjectSets
function processSetToSet(set1, set2, infoSet = null){
let i = 0, j = 0;
let hasCollision = false;
for (i = 0; i < set1.size(); i++) {
let s1 = set1.getObjectAt(i).getRigidBody();
for (j = 0; j < set2.size(); j++) {
let s2 = set2.getObjectAt(j).getRigidBody();
hasCollision = collideShape(s1, s2, infoSet) || hasCollision;
}
}
return hasCollision;
}
export {
// collide two shapes
collideShape,
// Collide
processSet, processObjToSet, processSetToSet
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
修改我的游戏来测试圆圈碰撞
测试新定义的碰撞功能所需的修改相当简单:
-
编辑 my _ game _ main.js 在构造函数中,定义用于存储
CollisionInfo
的数组和一个新标志,该标志指示是否应该绘制CollisionInfo
: -
修改
update()
函数以触发碰撞测试:
constructor() {
super();
... identical to previous code ...
this.mCollisionInfos = [];
... identical to previous code ...
// Draw controls
this.mDrawCollisionInfo = true; // showing of collision info
... identical to previous code ...
}
- 修改
draw()
函数,在定义时绘制创建的CollisionInfo
数组:
update() {
... identical to previous code ...
if (this.mDrawCollisionInfo)
this.mCollisionInfos = [];
else
this.mCollisionInfos = null;
engine.physics.processObjToSet(this.mHero,
this.mPlatforms, this.mCollisionInfos);
engine.physics.processSetToSet(this.mAllObjs,
this.mPlatforms, this.mCollisionInfos);
engine.physics.processSet(this.mAllObjs, this.mCollisionInfos);
}
- 记得更新
drawControlUpdate()
函数以支持 C 键切换CollisionInfo
对象的绘制:
draw() {
... identical to previous code ...
if (this.mCollisionInfos !== null) {
for (let i = 0; i < this.mCollisionInfos.length; i++)
this.mCollisionInfos[i].draw(this.mCamera);
this.mCollisionInfos = [];
}
... identical to previous code ...
}
drawControlUpdate() {
let i;
if (engine.input.isKeyClicked(engine.input.keys.C)) {
this.mDrawCollisionInfo = !this.mDrawCollisionInfo;
}
... identical to previous code ...
}
观察
现在,您可以运行该项目,以结果CollisionInfo
对象的形式检查您在RigidCircle
形状之间的碰撞实现。请记住,您只实现了圈-圈碰撞。现在,使用左/右箭头键选择并操作一个RigidCircle
对象。使用 WASD 键移动该对象,观察当它与另一个RigidCircle
重叠时代表碰撞法线和深度的洋红色线段。尝试键入 Y/U 键来验证不同半径形状的CollisionInfo
的正确性。现在,键入 G 键来创建更多的RigidCircle
对象。尝试移动所选对象并增加其大小,使其同时与多个RigidCircle
对象碰撞,并观察是否为每个碰撞计算了适当的CollisionInfo
。最后,注意你可以用 C 键切换CollisionInfo
的图形。
现在,您已经实现了循环碰撞检测,构建了支持碰撞所需的引擎基础设施,并验证了系统的正确性。现在,您已经准备好学习分离轴定理(SAT ),并实现一个派生的算法来检测矩形之间的冲突。
分离轴定理
分离轴定理(SAT)是 2D 用于检测一般凸形之间碰撞的最流行算法之一的基础。由于推导出的算法可能是计算密集型的,因此通常在宽相位方法的初始阶段之前。SAT 声明:如果存在一条垂直于两个多边形的给定边之一的线(或轴),当将两个多边形的所有边投影到该轴上时,投影的边不会重叠,则两个凸多边形不会碰撞。
换句话说,给定 2D 空间中的两个凸形,迭代通过凸形的所有边,一次一个。对于每条边,导出一条垂直于该边的线(或轴),将两个凸形的所有边投影到这条线上,并计算投影边的重叠部分。如果您可以找到一条没有投影边重叠的垂直线,那么这两个凸形就不会碰撞。
图 9-7 使用两个轴对齐的矩形说明了该描述。在这种情况下,有两条直线垂直于两个给定形状的边,即 X 轴和 Y 轴。
图 9-7
投影边缘不重叠的线
将形状的所有边缘投影到这两条直线/轴上时,请注意 Y 轴上的投影结果重叠,而 X 轴上没有重叠。由于存在一条垂直于矩形边之一的线,在该线处投影的边不重叠,SAT 断定两个给定的矩形不冲突。
源自 SAT 的算法的主要优势在于,对于非碰撞形状,它具有早期退出能力。一旦检测到没有重叠投影边缘的轴,算法就可以报告没有碰撞,并且不需要继续对其他轴进行测试。在图 9-7 的情况下,如果算法从处理 X 轴开始,则不需要执行 Y 轴的计算。
一种简单的 SAT 算法
基于 SAT 导出的算法通常由四个步骤组成。注意,该算法适用于检测任何凸形之间的碰撞。为了清楚起见,在下面的解释中,每个步骤都附有一个由两个矩形组成的简单示例:
图 9-8
矩形边和面法线
- 第一步。 计算面法线:计算投影边的垂直轴或面法线。以矩形为例,图 9-8 显示有四条边,每条边都有相应的垂直轴。例如,A1 是的对应轴,因此垂直于边 e A1 。请注意,在您的
RigidRectangle
类中,mFaceNormal
或面法线是垂直轴 A1、A2、A3 和 A4。
图 9-9
将每个顶点投影到面法线上(显示 A1)
- 第二步。 投影顶点:将两个凸形的顶点投影到面法线上。对于给定的矩形示例,图 9-9 显示了将所有顶点从图 9-8 投影到 A1 轴上。
图 9-10
确定每个矩形的最小和最大边界位置
- 第三步。 识别边界:识别每个凸形投影顶点的最小和最大边界。继续矩形的例子;图 9-10 显示了两个矩形的最小和最大位置。请注意,最小/最大位置是相对于给定轴的方向定义的。
图 9-11
测试投影边的重叠(显示 A1)
- 第四步。确定 重叠:确定两个最小/最大边界是否重叠。图 9-11 显示两个投影边界确实重叠。在这种情况下,算法不能结束,必须继续处理下一个面法线。注意,如图 9-8 所示,面法线 B1 或 B3 的处理将导致无碰撞的确定性结论。
给定的算法能够在没有附加信息的情况下确定是否发生了碰撞。回想一下,在检测到碰撞后,物理引擎还必须解决潜在的相互渗透,并为碰撞的形状获得响应。这两种计算都需要额外的信息——图 9-4 中介绍的碰撞信息。下一节将介绍一种有效的基于 SAT 的算法,该算法计算支持点,以告知碰撞检测的真/假结果,并作为导出碰撞信息的基础。
一种有效的 SAT 算法:支持点
如图 9-12 所示,形状 A 的面法线的支撑点被定义为形状 B 上的顶点位置,该顶点距离形状 A 的相应边的负距离最大。当沿 A1 方向测量时,形状 B 上的顶点 S A1 距离边 e A1 的负距离最大,因此,S A1 是面法线 A1 的支撑点。负距离表示测量是有方向的,并且支持点必须与面法线方向相反。
图 9-12
面法线的支持点
一般来说,给定面法线的支持点在每个更新周期中可能不同,因此必须在每次碰撞调用中重新计算。此外,非常重要的是,面法线完全可能没有定义的支持点。
面法线可能不存在支持点
仅当沿面法线测量的距离为负值时,才定义支持点。例如,在图 9-12 中,形状 B 的面法线 B1 在形状 A 上没有对应的支撑点。这是因为当沿着 B1 测量时,形状 A 上的所有顶点都与对应的边 e B1 相距正距离。正距离表示形状 A 的所有顶点都在边 e B1 的前面。换句话说,整个形状 A 在形状 B 的边 e B1 的前面;因此,这两个形状没有物理接触;因此,它们没有碰撞。
因此,当计算两个形状之间的碰撞时,如果任何一个面法线都没有相应的支撑点,那么这两个形状就没有碰撞。同样,早期退出能力是一个重要的优势——一旦检测到第一个未定义的支持点,算法就可以返回一个决定。
为了讨论和实现的方便,支持点和相应边缘之间的距离被称为支持点距离,并且该距离被计算为正数。这样,支撑点距离实际上是沿着负面法线方向测量的。这将是本书其余讨论中遵循的惯例。
最少穿透和碰撞信息的轴
当为凸形的所有面法线定义支持点时,最小支持点距离的面法线是导致最小贯穿的轴。图 9-13 显示了两个形状之间的碰撞,其中定义了形状 B 的所有面法线的支撑点:形状 A 上的顶点 S B1 是面法线 B1 的对应支撑点,S B2 是面法线 B2 的对应支撑点,以此类推。在这种情况下,S B1 具有最小的对应支撑点距离,因此,面法线 B1 是导致最小贯穿的轴。图 9-13 右图显示,在这种情况下,支撑点距离为碰撞深度,面法线 B1 为碰撞法线,支撑点 S B1 为碰撞的起点,碰撞的终点很容易计算;简单来说就是 S B1 在碰撞法线方向偏移碰撞深度。
图 9-13
最小穿透轴和相应的碰撞信息
该算法
根据背景描述,计算两个凸形 A 和 B 之间的碰撞的有效的基于 SAT 的算法可以概括为
Compute the support points for all the face normals on shape-A
If any of the support points is not defined, there is no collision
If all support points defined, compute the axis of least penetration
Compute the support points for all the face normals on shape-B
If any of the support points is not defined, there is no collision
If all support points defined, compute the axis of least penetration
碰撞信息只是前两个结果中较小的碰撞深度。您现在已经准备好实现支持点 SAT 算法了。
矩形碰撞项目
这个项目将指导你实现支持点 SAT 算法。你可以在图 9-14 中看到这个项目运行的例子。该项目的源代码在chapter9/9.3.rectangle_collisions
中定义。
图 9-14
运行矩形碰撞项目
该项目的控件与之前的项目相同:
-
行为控制:
- G 键:随机创建一个新的刚性圆或矩形
-
绘制控件:
-
C 键:切换所有
CollisionInfo
的绘制 -
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象。
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。
-
该项目的目标如下:
-
深入了解并实施支持点 SAT 算法
-
以继续完成窄相位冲突检测实现。
在这个项目之后,你的游戏引擎将能够在圆形和矩形之间碰撞,但仍然不支持圆形和矩形之间的碰撞。这将离完成刚性形状的窄相位碰撞检测更*一步。其余的功能,检测圆-矩形碰撞,将在下一小节中介绍。
实施支持点 SAT
前一个项目中的碰撞检测基础设施已经完成,唯一需要的修改是向RigidRectangle
类添加新的功能。回想一下,源代码文件rigid_rectangle_collision.js
是为了实现矩形碰撞而创建的。
-
在
src/engine/rigid_shapes
文件夹中,编辑rigid_rectangle_collision.js
以定义局部变量。这些是计算期间的临时存储;它们是静态分配和重用的,以避免每次调用期间重复动态分配的成本。 -
创建一个新函数
findSupportPoint()
,根据dir
、反向面法线方向ptOnEdge
、给定边上的位置(如顶点)计算支持点。列出的代码遍历所有顶点;计算vToEdge
,顶点到ptOnEdge
的向量;将这个向量投影到输入端dir
;并记录最大的正投影距离。回想一下dir
是否定的面法线方向,因此,最大的正距离对应最远的顶点位置。请注意,所有的投影距离都有可能是负的。在这种情况下,所有顶点都在输入方向的前面,给定边不存在支撑点,因此,两个矩形不会碰撞。
class SupportStruct {
constructor() {
this.mSupportPoint = null;
this.mSupportPointDist = 0;
}
}
// temp work area to save memory allocations
let mTmpSupport = new SupportStruct();
let mCollisionInfoR1 = new CollisionInfo();
let mCollisionInfoR2 = new CollisionInfo();
- 有了定位任何面法线的支持点的能力,下一步就是用
findAxisLeastPenetration()
功能找到最小穿透轴。回想一下,穿透最少的轴是距离支撑点最*的支撑点。列出的代码在四个面法线上循环,找到相应的支持点和支持点距离,并记录最短距离。while 循环表示,如果没有为任何面法线定义支持点,那么这两个矩形不会发生碰撞。
RigidRectangle.prototype.findSupportPoint = function (dir, ptOnEdge) {
// the longest project length
let vToEdge = [0, 0];
let projection;
mTmpSupport.mSupportPointDist = -Number.MAX_VALUE;
mTmpSupport.mSupportPoint = null;
// check each vector of other object
for (let i = 0; i < this.mVertex.length; i++) {
vec2.subtract(vToEdge, this.mVertex[i], ptOnEdge);
projection = vec2.dot(vToEdge, dir);
// find the longest distance with certain edge
// dir is -n direction, so the distance should be positive
if ((projection > 0) &&
(projection > mTmpSupport.mSupportPointDist)) {
mTmpSupport.mSupportPoint = this.mVertex[i];
mTmpSupport.mSupportPointDist = projection;
}
}
}
- 您现在可以通过计算两个矩形的最小穿透轴并选择两个结果中较小的一个来实现
collideRectRect()
功能:
RigidRectangle.prototype.findAxisLeastPenetration = function (otherRect, collisionInfo) {
let n;
let supportPoint;
let bestDistance = Number.MAX_VALUE;
let bestIndex = null;
let hasSupport = true;
let i = 0;
let dir = [0, 0];
while ((hasSupport) && (i < this.mFaceNormal.length)) {
// Retrieve a face normal from A
n = this.mFaceNormal[i];
// use -n as direction and the vertex on edge i as point on edge
vec2.scale(dir, n, -1);
let ptOnEdge = this.mVertex[i];
// find the support on B
// the point has longest distance with edge i
otherRect.findSupportPoint(dir, ptOnEdge);
hasSupport = (mTmpSupport.mSupportPoint !== null);
// get the shortest support point depth
if ((hasSupport) && (mTmpSupport.mSupportPointDist < bestDistance)) {
bestDistance = mTmpSupport.mSupportPointDist;
bestIndex = i;
supportPoint = mTmpSupport.mSupportPoint;
}
i = i + 1;
}
if (hasSupport) {
// all four directions have support point
let bestVec = [0, 0];
vec2.scale(bestVec, this.mFaceNormal[bestIndex], bestDistance);
let atPos = [0, 0];
vec2.add(atPos, supportPoint, bestVec);
collisionInfo.setInfo(bestDistance, this.mFaceNormal[bestIndex], atPos);
}
return hasSupport;
}
- 通过修改
collisionTest()
函数来调用新定义的collideRectRect()
函数来计算两个矩形之间的冲突,从而完成实现:
Rectangle.prototype.collideRectRect = function (r1, r2, collisionInfo) {
var status1 = false;
var status2 = false;
// find Axis of Separation for both rectangle
status1 = r1.findAxisLeastPenetration(r2, collisionInfoR1);
if (status1) {
status2 = r2.findAxisLeastPenetration(r1, collisionInfoR2);
if (status2) {
// if rectangles overlap, the shorter normal is the normal
if (collisionInfoR1.getDepth()<collisionInfoR2.getDepth()) {
var depthVec = collisionInfoR1.getNormal().scale(
collisionInfoR1.getDepth());
collisionInfo.setInfo(collisionInfoR1.getDepth(),
collisionInfoR1.getNormal(),
collisionInfoR1.mStart.subtract(depthVec));
} else {
collisionInfo.setInfo(collisionInfoR2.getDepth(),
collisionInfoR2.getNormal().scale(-1),
collisionInfoR2.mStart);
}
}
}
return status1 && status2;
}
RigidRectangle.prototype.collisionTest =
function (otherShape, collisionInfo) {
let status = false;
if (otherShape.mType === "RigidCircle") {
status = false;
} else {
status = this.collideRectRect(this, otherShape, collisionInfo);
}
return status;
}
观察
现在,您可以运行项目来测试您的实现。您可以使用左/右箭头键选择任何刚性形状,并使用 WASD 键移动选定的对象。同样,您可以观察到重叠矩形或重叠圆形之间的洋红色碰撞信息。请记住,这条线显示了确保形状之间没有重叠所需的最少量的位置校正。键入 Z/X 键以旋转,键入 Y/U 键以更改选定对象的大小,并观察碰撞信息如何相应地变化。
此时,仅支持圆-圆和矩形-矩形碰撞,因此当圆和矩形重叠时,不会显示碰撞信息。这将在下一个项目中解决。
矩形和圆形之间的碰撞
支持点算法不适用于圆,因为圆没有可识别的顶点位置。相反,您将实现一个算法,根据圆心相对于矩形的相对位置来检测矩形和圆形之间的冲突。
在讨论实际算法之前,如图 9-15 所示,通过延伸连接边,可以很方便地将矩形边外的区域分为三个不同的区域。在这种情况下,虚线将给定边缘外的区域分成RG1
,左边/顶部的区域;RG2
,该区域向右/下;以及RG3
,紧接在给定边缘之外的区域。
在这种背景下,矩形和圆形之间的碰撞可以检测如下:
图 9-15
矩形给定边外的三个区域
-
步骤 A :计算矩形上最接*圆心的边。
-
步骤 B :如果圆心在矩形内,检测到碰撞。
-
步骤 C :如果圆心在外面
-
步 C1 :如果在区域
RG1
,圆心和顶点之间的距离决定是否发生碰撞。 -
步 C2 :如果在区域
RG2
,圆心和底部顶点之间的距离决定是否发生碰撞。 -
步 C3 :如果在区域
RG3
,中心和边缘之间的垂直距离决定是否发生碰撞。
-
矩形和圆形碰撞项目
这个项目指导你实现所描述的矩形-圆形碰撞检测算法。你可以在图 9-16 中看到这个项目运行的例子。该项目的源代码在chapter9/9.4.rectangle_and_circle_collisions
中定义。
图 9-16
运行矩形和圆形碰撞项目
该项目的控件与之前的项目相同:
-
行为控制:
- G 键:随机创建一个新的刚性圆或矩形
-
绘制控件:
-
C 键:切换所有
CollisionInfo
的绘制 -
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象。
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。
-
该项目的目标如下:
-
理解和实现矩形圆碰撞检测算法
-
为了完成圆形和矩形的窄相位碰撞检测实现
定义矩形-圆形碰撞
同样,有了完整的冲突检测基础设施,唯一需要的修改是添加新的功能。这将在RigidRectangle
类中实现。为了使相当复杂的算法具有可读性,将为实现创建一个新的源代码文件rigid_rectangle_circle_collision.js
。
-
更新
RigidRectangle
access 文件,从最新的源代码文件导入。在src/engine/rigid_shapes
文件夹中,编辑rigid_rectangle.js
以替换从最新源代码文件导入的内容。 -
在同一个文件夹中,创建从
rigid_rectangle_collision.js
导入的rigid_rectangle_circle_collision.js
文件,这样就可以将新的碰撞函数追加到类中:
import RigidRectangle from "./rigid_rectangle_circle_collision.js";
export default RigidRectangle;
- 定义一个新函数
checkCircRectVertex()
,来处理区域RG1
和RG2
。如图 9-17 左图所示,参数v1
是顶点位置到圆心的矢量。图 9-17 右图显示当v1
的长度dist
小于r
的半径时发生碰撞。在这种情况下,碰撞深度就是r
和dist
之差。
import RigidRectangle from "./rigid_rectangle_collision.js";
图 9-17
左:中心位于区域 RG1 时的情况。右图:相应的碰撞信息
RigidRectangle.prototype.checkCircRectVertex =
function(v1, cirCenter, r, info) {
// the center of circle is in corner region of mVertex[nearestEdge]
let dist = vec2.length(v1);
// compare the distance with radius to decide collision
if (dist > r)
return false;
let radiusVec = [0, 0];
let ptAtCirc = [0, 0];
vec2.scale(v1, v1, 1/dist); // normalize
vec2.scale(radiusVec, v1, -r);
vec2.add(ptAtCirc, cirCenter, radiusVec);
info.setInfo(r - dist, v1, ptAtCirc);
return true;
}
-
定义
collideRectCirc()
函数来检测矩形和圆形之间的碰撞。下面的代码显示了局部变量的声明和必须执行的五个主要步骤,从 A 到 C3。本小节的其余部分将讨论每个步骤的详细信息。 -
步骤 A,计算最*边。通过计算圆心和矩形每条边之间的垂直距离,可以找到最*的边。这个距离就是从每个顶点到圆心的向量在相应面法线上的投影。列出的代码遍历所有顶点,计算从顶点到圆心的向量,并将计算的向量投影到相应的面法线。
RigidRectangle.prototype.collideRectCirc =
function (otherCir, collisionInfo) {
let outside = false;
let bestDistance = -Number.MAX_VALUE;
let nearestEdge = 0;
let vToC = [0, 0];
let projection = 0;
let i = 0;
let cirCenter = otherCir.getCenter();
... Step A: Compute nearest edge, handle if center is inside ...
if (!outside) {
... Step B: Circle center is inside rectangle ...
return;
}
... Steps C1 to C3: Circle center is outside rectangle ...
return true;
};
// Step A: Compute the nearest edge
while ((!outside) && (i<4)) {
// find the nearest face for center of circle
vec2.subtract(vToC, cirCenter, this.mVertex[i]);
projection = vec2.dot(vToC, this.mFaceNormal[i]);
if (projection > bestDistance) {
outside = (projection > 0); // if projection < 0, inside
bestDistance = projection;
nearestEdge = i;
}
i++;
}
如图 9-18 的左图所示,当圆心在矩形内时,所有顶点到中心的矢量将与它们对应的面法线方向相反,因此将导致负投影长度。这与图 9-18 的右图相反,图中的中心在矩形之外。在这种情况下,至少有一个投影长度为正。因此,“最*的投影距离”是负值最小的距离,因此实际上是最大的数值。
图 9-18
左:矩形内的中心将导致所有负投影长度。右:矩形外的中心将导致至少一个正投影长度
-
步骤 B,如果圆心在矩形内,则检测到碰撞,计算并返回相应的碰撞信息:
-
步骤 C1,判断并处理圆心是否在
RG1
区域。如图 9-17 左图所示,当中心与顶点之间的矢量v1
与边缘方向v2
相反时,可以检测到区域RG1
。该条件在下面列出的代码中进行计算:
if (!outside) { // inside
// Step B: The center of circle is inside of rectangle
vec2.scale(radiusVec,this.mFaceNormal[nearestEdge],otherCir.mRadius);
dist = otherCir.mRadius - bestDistance; // bestDist is -ve
vec2.subtract(ptAtCirc, cirCenter, radiusVec);
collisionInfo.setInfo(dist, this.mFaceNormal[nearestEdge], ptAtCirc);
return true;
}
- 步骤 C2 和 C3,区分和处理区域
RG2
和RG3
。列出的代码为区域RG2
的同一矩形边上的另一个顶点执行互补计算。圆心要位于的最后一个区域将是紧邻最*边缘之外的区域。在这种情况下,先前在步骤 A 中计算的bestDistance
是圆心和给定边缘之间的距离。如果这个距离小于圆的半径,那么就发生了碰撞。
let v1 = [0, 0], v2 = [0, 0];
vec2.subtract(v1, cirCenter, this.mVertex[nearestEdge]);
vec2.subtract(v2, this.mVertex[(nearestEdge + 1) % 4],
this.mVertex[nearestEdge]);
let dot = vec2.dot(v1, v2);
if (dot < 0) {
// Step C1: In Region RG1
return this.checkCircRectVertex(v1, cirCenter,
otherCir.mRadius, collisionInfo);
} else {
... implementation of Steps C2 and C3 to follow ...
}
if (dot < 0) {
// Step C1: In Region RG1
... identical to previous code ...
} else {
// Either in Region RG2 or RG3
// v1 is from right vertex of face to center of circle
// v2 is from right vertex of face to left vertex of face
vec2.subtract(v1, cirCenter, this.mVertex[(nearestEdge + 1) % 4]);
vec2.scale(v2, v2, -1);
dot = vec2.dot(v1, v2);
if (dot < 0) {
// Step C2: In Region RG2
return this.checkCircRectVertex(v1, cirCenter,
otherCir.mRadius, collisionInfo);
} else {
// Step C3: In Region RG3
if (bestDistance < otherCir.mRadius) {
vec2.scale(radiusVec,
this.mFaceNormal[nearestEdge], otherCir.mRadius);
dist = otherCir.mRadius - bestDistance;
vec2.subtract(ptAtCirc, cirCenter, radiusVec);
collisionInfo.setInfo(dist,
this.mFaceNormal[nearestEdge], ptAtCirc);
return true;
} else {
return false;
}
}
}
调用新定义的函数
最后一步是调用新定义的函数。注意,当一个圆接触到一个矩形时,以及当一个矩形接触到一个圆时,都应该调用碰撞函数。因此,您必须修改rigid_rectangle_collision.js
中的RigidRectangle
类和rigid_circle_collision.js
中的RigidCircle
类。
-
在
src/engine/rigid_shapes
文件夹中,编辑rigid_rectangle_collision.js
,修改collisionTest()
函数,当参数为圆形时,调用新定义的collideRectCirc()
: -
在同一个文件夹中,编辑
rigid_circle_collision.js
,修改collisionTest()
函数,当参数为矩形时,调用新定义的collideRectCirc()
:
RigidRectangle.prototype.collisionTest =
function (otherShape, collisionInfo) {
let status = false;
if (otherShape.mType === "RigidCircle") {
status = this.collideRectCirc(otherShape, collisionInfo);
} else {
status = this.collideRectRect(this, otherShape, collisionInfo);
}
return status;
}
RigidCircle.prototype.collisionTest =
function (otherShape, collisionInfo) {
let status = false;
if (otherShape.mType === "RigidCircle") {
status = this.collideCircCirc(this, otherShape, collisionInfo);
} else {
status = otherShape.collideRectCirc(this, collisionInfo);
}
return status;
}
观察
现在,您可以运行项目来测试您的实现。您可以创建新的矩形和圆形,移动和旋转它们以观察相应的碰撞信息。
您终于完成了窄阶段碰撞检测实现,可以开始检查这些刚性形状的运动了。
活动
运动是对模拟世界中物体位置如何变化的描述。从数学上讲,运动可以用多种方式来表达。在第六章中,你体验了与运动一起工作,在运动中你不断地积累到一个物体位置的位移。如下式和图 9-19 所示,你一直在描述基于恒定位移的运动。
图 9-19
基于恒定位移的运动
- p 新 = p 当前 +位移
当需要随时间改变位移量时,由恒定位移公式控制的运动变得受限。牛顿力学通过在运动公式中考虑时间来解决这一限制,如以下等式所示:
-
v 新 = v 当前 + ∫ a(t)dt
-
p 新 = p 当前 + ∫ v(t)dt
这两个方程表示基于牛顿的运动,其中 v(t)是描述位置随时间变化的速度,a(t)是描述速度随时间变化的加速度。
注意,速度和加速度都是矢量,对大小和方向进行编码。速度向量的大小定义了速度,而归一化的速度向量标识了对象行进的方向。加速度矢量让你知道一个物体是加速还是减速,以及物体运动方向的变化。加速度因作用在物体上的力而改变。例如,如果你将一个球抛向空中,重力会随着时间的推移影响物体的加速度,这反过来会改变物体的速度。
显式欧拉积分
欧拉方法,或显式欧拉积分,基于初始值*似积分。这是积分最直接的*似方法之一。如下面两个等式所示,在牛顿运动公式的情况下,物体的新速度 v new 可以*似为当前速度 v current 加上当前加速度 a current 乘以经过的时间。类似地,对象的新位置 p new 可以通过对象的当前位置 p current 加上当前速度 v current 乘以经过的时间来*似。
-
v 新 = v 电流 + a 电流∫dt
-
p 新 = p 电流 + v 电流∫dt
图 9-20 的左图说明了一个用显式欧拉积分*似运动的简单例子。注意,新位置 p new 是基于当前速度 v current 计算的,而新速度 v new 是为下一个更新周期移动位置而计算的。
图 9-20
显式(左)和辛(右)欧拉积分
辛欧拉积分
你将实现半隐式欧拉积分或辛欧拉积分。通过辛欧拉积分,在随后的*似中使用中间结果而不是当前结果,从而更好地模拟实际运动。下面的方程显示了辛欧拉积分。注意,除了在计算新位置 p new 时使用新速度 v new 之外,它几乎与欧拉方法相同。这实质上意味着下一帧的速度被用来计算该帧的位置。
-
v 新 = v 电流 + a 电流∫dt
-
p 新 = p 当前 + v 新∫dt
图 9-20 的右图说明了利用辛欧拉积分,基于新计算的速度 v new 计算新的位置 p new 。
刚性形状运动项目
你现在准备好实施辛欧拉积分来*似运动。固定时间步长 dt 公式方便地允许每个更新周期评估一次积分。这个项目将指导你使用RigidShape
类来支持辛欧拉积分的运动*似。你可以在图 9-21 中看到这个项目运行的例子。这个项目的源代码在chapter9/9.5.rigid_shape_movements
中定义。
图 9-21
运行刚性形状运动项目
项目的控件与之前的控件相同,只是添加了一些命令来控制选定对象的行为和质量:
-
行为控制:
-
V 键:切换所有对象的运动
-
H 键:给所有物体注入随机速度
-
G 键:随机创建一个新的刚性圆或矩形
-
-
绘制控件:
-
C 键:切换所有
CollisionInfo
的绘制 -
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象。
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。 -
上下箭头键+ M :增加/减少所选对象的质量。
-
该项目的目标如下:
-
完成
RigidShape
类的实现,以包含相关的物理属性 -
基于辛欧拉积分实现运动逼*
除了实现辛欧拉积分,这个项目还指导你定义碰撞模拟和响应所需的属性,如质量、惯性、摩擦力等。正如将要解释的,这些属性中的每一个都将在对象碰撞响应的模拟中起作用。这里提供的简单信息是为了避免分散对后续项目中涉及的更复杂概念的讨论。
在本节的剩余部分,您将首先定义相关的物理属性来完成RigidShape
实现。之后,您将专注于构建对*似运动的辛欧拉积分支持。
完成 RigidShape 实现
如前所述,为了在后面的章节中集中讨论更复杂的概念,在这个项目中引入了支持碰撞的属性和相应的支持功能。这些属性在刚性形状类中定义。
修改 RigidShape 类
编辑src/engine/rigid_shape
文件夹中的rigid_shape.js
:
-
在
RigidShape
类的构造函数中,定义代表加速度、速度、角速度、质量、转动惯量、恢复力(弹性)和摩擦力的变量。注意,质量值的倒数实际上是为了计算效率而存储的(通过在每次更新期间避免额外的除法)。此外,请注意,零质量用于表示静止物体。 -
定义
setMass()
函数来设置物体的质量。同样,为了提高计算效率,存储质量的倒数。将物体的质量设置为零或负值是一个信号,表明该物体是静止的,加速度为零,不会参与任何运动计算。注意,当一个物体的质量改变时,你需要调用updateInertia()
来更新它的转动惯量,mInertial
。转动惯量是几何形状特定的,而updateIntertia()
的实现是子类特定的。
class RigidShape {
constructor(xf) {
this.mXform = xf;
this.mAcceleration = physics.getSystemAcceleration();
this.mVelocity = vec2.fromValues(0, 0);
this.mType = "";
this.mInvMass = 1;
this.mInertia = 0;
this.mFriction = 0.8;
this.mRestitution = 0.2;
this.mAngularVelocity = 0;
this.mBoundRadius = 0;
this.mDrawBounds = false;
}
-
为所有其他相应的变量定义 getter 和 setter 函数。这些函数很简单,这里没有列出。
-
为了便于调试,定义一个函数
getCurrentState()
以文本形式检索变量值,定义一个函数userSetsState()
以允许变量的交互操作:
setMass(m) {
if (m > 0) {
this.mInvMass = 1 / m;
this.mAcceleration = physics.getSystemAcceleration();
} else {
this.mInvMass = 0;
this.mAcceleration = [0, 0]; // to ensure object does not move
}
this.updateInertia();
}
getCurrentState() {
let m = this.mInvMass;
if (m !== 0)
m = 1 / m;
return "M=" + m.toFixed(kPrintPrecision) +
"(I=" + this.mInertia.toFixed(kPrintPrecision) + ")" +
" F=" + this.mFriction.toFixed(kPrintPrecision) +
" R=" + this.mRestitution.toFixed(kPrintPrecision);
}
userSetsState() {
// keyboard control
let delta = 0;
if (input.isKeyPressed(input.keys.Up)) {
delta = kRigidShapeUIDelta;
}
if (input.isKeyPressed(input.keys.Down)) {
delta = -kRigidShapeUIDelta;
}
if (delta !== 0) {
if (input.isKeyPressed(input.keys.M)) {
let m = 0;
if (this.mInvMass > 0)
m = 1 / this.mInvMass;
this.setMass(m + delta * 10);
}
if (input.isKeyPressed(input.keys.F)) {
this.mFriction += delta;
if (this.mFriction < 0)
this.mFriction = 0;
if (this.mFriction > 1)
this.mFriction = 1;
}
if (input.isKeyPressed(input.keys.R)) {
this.mRestitution += delta;
if (this.mRestitution < 0)
this.mRestitution = 0;
if (this.mRestitution > 1)
this.mRestitution = 1;
}
}
}
修改 RigidCircle 类
如上所述,转动惯量mInertial
特定于几何形状,必须通过相应的类别进行修改:
-
编辑
src/engine/rigid_shapes
文件夹中的rigid_circle_main.js
,修改RigidCircle
类,定义updateInertia()
函数。该函数计算质量变化时圆的转动惯量。 -
更新
RigidCircle
构造函数和incShapeSize()
函数来调用updateInertia()
函数:
updateInertia() {
if (this.mInvMass === 0) {
this.mInertia = 0;
} else {
// this.mInvMass is inverted!!
// Inertia=mass * radius²
this.mInertia = (1 / this.mInvMass) *
(this.mRadius * this.mRadius) / 12;
}
};
constructor(xf, radius) {
super(xf);
... identical to previous code ...
this.updateInertia();
}
incShapeSizeBy(dt) {
... identical to previous code ...
this.updateInertia();
}
修改 RigidRectangle 类
必须为RigidRectangle
类定义类似于RigidCircle
类的修改:
-
编辑
src/engine/rigid_shapes
文件夹中的rigid_rectangle_main.js
,定义updateInertia()
功能: -
类似于
RigidCircle
类,更新构造函数和incShapeSize()
函数来调用updateInertia()
函数:
updateInertia() {
// Expect this.mInvMass to be already inverted!
if (this.mInvMass === 0)
this.mInertia = 0;
else {
// inertia=mass*width²+height²
this.mInertia = (1 / this.mInvMass) *
(this.mWidth * this.mWidth +
this.mHeight * this.mHeight) / 12;
this.mInertia = 1 / this.mInertia;
}
}
constructor(xf, width, height) {
super(xf);
... identical to previous code ...
this.updateInertia();
}
incShapeSizeBy(dt) {
... identical to previous code ...
this.updateInertia();
}
定义系统加速和运动控制
完成RigidShape
实现后,您现在可以定义对运动*似的支持了。
通过向src/engine/components
文件夹中的physics.js
添加适当的变量和访问函数,定义系统范围的加速度和运动控制。记得导出新定义的功能。
let mSystemAcceleration = [0, -20]; // system-wide default acceleration
let mHasMotion = true;
// getters and setters
function getSystemAcceleration() {
return vec2.clone(mSystemAcceleration);
}
function setSystemAcceleration(x, y) {
mSystemAcceleration[0] = x;
mSystemAcceleration[1] = y;
}
function getHasMotion() { return mHasMotion; }
function toggleHasMotion() { mHasMotion = !mHasMotion; }
... identical to previous code ...
export {
// Physics system attributes
getSystemAcceleration, setSystemAcceleration,
getHasMotion, toggleHasMotion,
... identical to previous code ...
}
访问固定时间间隔
在您的游戏引擎中,固定的时间步长 dt 就是调用游戏循环组件中的loopOnce()
函数之间的时间间隔。现在,编辑src/engine/core
文件夹中的loop.js
,定义并导出更新时间间隔。
const kUPS = 60; // Updates per second
const kMPF = 1000 / kUPS; // Milliseconds per update.
const kSPU = 1/kUPS; // seconds per update
... identical to previous code ...
function getUpdateIntervalInSeconds() { return kSPU; }
... identical to previous code ...
export {getUpdateIntervalInSeconds}
在 RigidShape 类中实现辛欧拉积分
现在,您可以在刚性形状类中实现辛欧拉积分运动*似。因为这种移动行为对于所有类型的刚性形状都是常见的,所以实现应该位于基类RigidShape
中。
-
在
src/engine/rigid_shapes
文件夹中,编辑rigid_shape.js
定义travel()
函数,实现运动的辛欧拉积分。请注意,该实现是如何紧密遵循所列出的等式的,其中更新的速度用于计算新位置。此外,请注意线性运动和角运动之间的相似性,其中位置(位置或角度)是通过从速度和时间步长导出的位移来更新的。旋转将在本章的最后一节详细讨论。 -
修改
update()
函数,当对象不静止时调用travel()
,mInvMass
为 0,当物理组件的运动打开时:
travel() {
let dt = loop.getUpdateIntervalInSeconds();
// update velocity by acceleration
vec2.scaleAndAdd(this.mVelocity,
this.mVelocity, this.mAcceleration, dt);
// p = p + v*dt with new velocity
let p = this.mXform.getPosition();
vec2.scaleAndAdd(p, p, this.mVelocity, dt);
this.mXform.incRotationByRad(this.mAngularVelocity * dt);
}
update() {
if (this.mInvMass === 0)
return;
if (physics.getHasMotion())
this.travel();
}
修改我的游戏来测试动作
对MyGame
类的修改包括支持新的用户命令,用于切换系统范围的运动,注入随机速度,以及将场景静止边界对象设置为零质量的刚性形状。随机速度的注入由my_game_bounds.js
文件中定义的randomizeVelocity()
函数实现。
对MyGame
类的所有更新都很简单。为了避免不必要的干扰,细节没有显示。与往常一样,您可以参考src/my_game
文件夹中的源代码文件来了解实现细节。
观察
现在,您可以运行项目来测试您的实现。为了正确地观察和跟踪物体的运动,最初运动被关闭。准备好之后,您可以键入 V 键来启用动作。打开“运动”时,您可以观察到所有对象自然的自由落体运动。您可以键入 G 来创建更多对象,并观察所创建对象的类似自由落体运动。
请注意,当对象低于较低的*台时,它们会在场景的中心区域以随机的初始向上速度重新生成。观察物体向上移动,直到速度的 y 分量为零,然后它们开始由于重力加速度而向下下落。按 H 键会给所有对象注入新的随机向上速度,导致对象在向上移动时减速。
当对象重叠或相互穿透时,尝试键入 C 键来观察计算的碰撞信息。请注意,当对象在场景中穿行时,会经常发生穿插。现在,您已经准备好在下一节中检查和实现如何解决对象渗透。
碰撞物体的相互穿透
上一个项目中引入的固定更新时间步长意味着连续运动中的对象的实际位置由一组离散的位置来*似。如图 9-22 所示,通过在三个更新周期内将对象放置在三个不同的位置,来*似矩形对象的移动。这种*似的最显著的衍生物是在确定物体之间的碰撞时的挑战。
图 9-22
连续运动的刚性矩形
你可以在图 9-22 中看到这样一个挑战。想象一下,在当前更新和下一次更新之间有一堵薄薄的墙。你会期望在下一次更新中物体会碰撞并停在墙边。然而,如果墙壁足够薄,当物体从一个位置跳到下一个位置时,它看起来会直接穿过墙壁。这是很多游戏引擎面临的普遍问题。这类问题的一般解决方案在算法上可能很复杂,而且计算量很大。游戏设计者的工作通常是用设计良好的(例如,适当的大小)和行为良好的(例如,适当的行进速度)游戏对象来减轻和避免这个问题。
图 9-23 显示了由固定更新时间步长产生的另一个更重要的冲突相关挑战。在这种情况下,在时间步长之前,对象没有接触。在时间步长之后,运动*似的结果将两个对象放置在它们部分重叠的地方。在现实世界中,如果两个对象是刚性的形状或固体,那么重叠或相互渗透就永远不会发生。因此,这种情况必须在刚体物理模拟中得到妥善解决。这就是必须计算碰撞细节的地方,这样就可以正确地解决类似这样的交叉情况。
图 9-23
碰撞物体的相互渗透
碰撞位置校正
在游戏引擎的上下文中,冲突解决指的是在冲突后确定对象响应的过程,包括解决可能已经发生的潜在渗透情况的策略。请注意,在现实世界中,刚性对象的相互渗透永远不会发生,因为碰撞严格受物理定律的约束。因此,相互渗透的分辨率仅在模拟的虚拟世界中相关,在该虚拟世界中,运动是*似的,并且可能发生不可能的情况。这些情况必须通过算法来解决,其中计算成本和结果视觉外观都应该是可接受的。
一般来说,有三种常见的方法来响应互穿碰撞。第一种是简单地通过穿透深度使物体彼此移位。这就是所谓的投影方法,因为您只需移动对象的位置,使它们不再重叠。虽然这很容易计算和实现,但是当许多对象彼此接*和重叠时,它缺乏稳定性。在这种情况下,一对相互渗透的对象的简单解析可以导致与其他附*对象的新渗透。但是,投影方法仍然经常在具有简单对象交互规则的简单引擎或游戏中实现。例如,在一场乒乓球比赛中,球永远不会停留在球拍或墙上,而是通过反弹任何与它碰撞的物体来保持连续运动。投影方法非常适合解决这些类型的简单对象交互的碰撞。
第二种方法是脉冲法,使用物体速度来计算并应用脉冲,以使物体在碰撞点向相反方向移动。这种方法倾向于快速减慢碰撞物体的速度,并收敛到相对稳定的解。这是因为冲量是根据动量转移计算的,而动量转移又对碰撞物体的速度产生阻尼效应。
第三种方法是罚函数法,将对象穿透的深度建模为弹簧的压缩程度,并*似为加速度,以施加力来分离对象。最后一种方法最复杂,也最难实现。
对于您的引擎,您将结合投影和脉冲方法的优势。投影方法将用于分离相互渗透的对象,而脉冲方法将用于计算脉冲,以降低导致相互渗透的方向上的对象速度。如上所述,简单的投影方法会导致系统不稳定,例如堆叠时物体会相互陷入。您将通过实现一个松弛循环来克服这种不稳定性,在该循环中,通过重复应用投影方法,在单个更新周期中逐渐分离相互穿透的对象。
对于松弛循环,投影方法的每次应用都被称为松弛迭代。在每次松弛迭代期间,投影方法以总渗透深度的固定百分比递增地减少渗透。例如,默认情况下,引擎将松弛迭代次数设置为 15,每次松弛迭代减少 80%的渗透。这意味着在一次更新函数调用中,在运动积分*似之后,碰撞检测和解决过程将被执行 15 次。虽然成本高,但重复的增量分离确保了系统的稳定。
碰撞位置校正项目
这个项目将指导你通过松弛迭代的实现来逐步解决物体间的相互渗透。您将使用从上一个项目中计算的碰撞信息来校正碰撞对象的位置。你可以在图 9-24 中看到这个项目运行的例子。该项目的源代码在chapter9/9.6.collision_position_correction
中定义。
图 9-24
运行碰撞位置校正项目
项目的控制与上一个项目相同,只是在“行为控制”中增加了一个 P 键盘命令:
-
行为控制:
-
P 键:切换所有物体的穿透分辨率
-
V 键:切换所有对象的运动
-
H 键:给所有物体注入随机速度
-
G 键:随机创建一个新的刚性圆或矩形
-
-
绘制控件:
-
C 键:切换所有
CollisionInfo
的绘制 -
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象。
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。 -
上下箭头键+ M :增加/减少所选对象的质量。
-
该项目的目标如下:
-
用松弛迭代实现位置校正
-
使用计算出的碰撞信息并意识到其重要性
-
理解并体验实现互穿解决方案
更新物理组件
之前的项目已经建立了所需的模拟基础设施,包括RigidShape
实施的完成。您现在可以关注位置校正逻辑的细节,它被本地化并隐藏在src/engine/components
文件夹的physics.js
文件的物理组件的核心中。
-
编辑
physics.js
为位置校正率、松弛循环计数和切换位置校正计算定义变量和相关的获取器和设置器。确保导出新定义的函数。 -
定义
positionalCorrection()
功能,以预定义的速率mPosCorrectionRate
移动和减少对象之间的重叠。为了在模拟中正确支持对象动量,每个对象的移动量与其质量成反比。也就是说,在碰撞时,质量较大的物体移动的量小于质量较小的物体。请注意,移动方向是沿着碰撞法线,由collisionInfo
对象定义。
let mPosCorrectionRate = 0.8; // % separation to project objects
let mRelaxationCount = 15; // number of relaxation iterations
let mCorrectPosition = true;
function getPositionalCorrection() { return mCorrectPosition; }
function togglePositionalCorrection() {
mCorrectPosition = !mCorrectPosition;
}
function getRelaxationCount() { return mRelaxationCount; }
function incRelaxationCount(dc) { mRelaxationCount += dc; }
... identical to previous code ...
export {
... identical to previous code ...
togglePositionalCorrection,
getPositionalCorrection,
getRelaxationCount,
incRelaxationCount
}
- 修改
collideShape()
功能,以在检测到碰撞时执行位置修正。请注意,仅当至少一个对象的质量不为零时,才会执行碰撞检测。
function positionalCorrection(s1, s2, collisionInfo) {
if (!mCorrectPosition)
return;
let s1InvMass = s1.getInvMass();
let s2InvMass = s2.getInvMass();
let num = collisionInfo.getDepth() /
(s1InvMass + s2InvMass) * mPosCorrectionRate;
let correctionAmount = [0, 0];
vec2.scale(correctionAmount, collisionInfo.getNormal(), num);
s1.adjustPositionBy(correctionAmount, -s1InvMass);
s2.adjustPositionBy(correctionAmount, s2InvMass);
}
- 在所有三个效用函数
processObjToSet()
、processSetToSet()
和processSet()
中集成一个循环,以在执行位置修正时执行松弛迭代:
function collideShape(s1, s2, infoSet = null) {
... identical to previous code ...
if ((s1 !== s2) &&
((s1.getInvMass() !== 0) || (s2.getInvMass() !== 0))) {
if (s1.boundTest(s2)) {
hasCollision = s1.collisionTest(s2, mCInfo);
if (hasCollision) {
vec2.subtract(mS1toS2, s2.getCenter(), s1.getCenter());
if (vec2.dot(mS1toS2, mCInfo.getNormal()) < 0)
mCInfo.changeDir();
positionalCorrection(s1, s2, mCInfo);
... identical to previous code ...
}
return hasCollision;
}
function processObjToSet(obj, set, infoSet = null) {
let j = 0, r = 0;
let hasCollision = false;
let s1 = obj.getRigidBody();
for (r = 0; r < mRelaxationCount; r++) {
for (j = 0; j < set.size(); j++) {
let s2 = set.getObjectAt(j).getRigidBody();
hasCollision = collideShape(s1, s2, infoSet) || hasCollision;
}
}
return hasCollision;
}
function processSetToSet(set1, set2, infoSet = null) {
let i = 0, j = 0, r = 0;
let hasCollision = false;
for (r = 0; r < mRelaxationCount; r++) {
... identical to previous code ...
}
return hasCollision;
}
// collide all objects in the GameObjectSet with themselves
function processSet(set, infoSet = null) {
let i = 0, j = 0, r = 0;
let hasCollision = false;
for (r = 0; r < mRelaxationCount; r++) {
... identical to previous code ...
}
return hasCollision;
}
在我的游戏中测试位置修正
必须修改MyGame
类以支持新的 P 键命令,关闭初始运动、位置修正,并在游戏场景的中心区域生成初始对象以保证初始碰撞。这些修改很简单,细节没有显示。与往常一样,您可以参考src/my_game
文件夹中的源代码文件来了解实现细节。
观察
现在,您可以运行项目来测试您的实现。请注意,默认情况下,运动处于禁用状态,位置校正处于禁用状态,碰撞信息显示处于启用状态。由于这些原因,您将观察到创建的刚性形状聚集在游戏场景的中心区域,带有许多关联的洋红色碰撞信息。
现在,键入 P 键,观察所有的形状被推开,所有的重叠都解决了。您可以键入 G 键来创建其他形状,并观察这些形状不断相互推开以确保没有重叠。一个有趣的实验是关闭位置校正,然后按 G 键创建大量重叠的形状,然后按 P 键观察形状相互推开。
如果你用 V 键打开运动,你将首先观察到所有物体在重力作用下自由落体。这些物体最终会停在一个固定的*台上。接下来,您将观察到洋红色碰撞深度在垂直方向上不断增加。这种尺寸的增加是由于重力加速度的下降而导致的下降速度不断增加的结果。最终,向下的速度将变得如此之大,以至于在一次更新中,物体将移动过去,并看起来正好穿过*台。你观察到的正是图 9-22 中讨论的情况。下一小节将讨论对碰撞的反应,并解决这个不断增加的速度。
最后,注意在物理组件中定义的效用函数,processSet()
、processObjToSet()
和processSetToSet()
,这些都是设计来检测和解决碰撞的。虽然这些函数很有用,但它们并不是设计用来报告是否发生了碰撞——这是典型物理引擎支持的常见操作。为了避免分散对刚性形状模拟讨论的注意力,没有呈现支持简单碰撞检测而没有响应的功能。此时,您已经具备了定义这些函数的必要知识,这是留给您来完成的一个练习。
冲突解决
有了适当的位置校正系统,您现在可以开始实现碰撞解决并支持类似真实世界情况的行为。为了将重点放在碰撞解决系统的核心功能上,包括理解和实施脉冲方法以及确保系统稳定性,您将从检查无旋转的碰撞响应开始。在完全理解并实现简单脉冲分辨率背后的机制之后,我们将在下一部分讨论与角度脉冲分辨率相关的复杂因素。
在下面的讨论中,矩形和圆形不会因碰撞而旋转。然而,所描述的概念和实现可以以直接的方式被推广以支持旋转碰撞响应。这个项目旨在帮助你理解基于脉冲的碰撞解决的基本概念。
脉冲法
你将通过首先回顾在一个完美的世界中,一个圆是如何从一面墙和其他圆上反弹回来,来制定脉冲法的解决方案。这将随后用于导出适当碰撞响应的*似值。注意,下面的讨论集中在推导冲量法的公式上,并不试图给出牛顿力学的综述。以下是一些相关术语的简要回顾:
-
质量:是一个物体中物质的数量或者说一个物体的密度有多大。
-
力:施加在物体上的任何相互作用或能量都会改变该物体的运动。
-
相对速度:两个行进形状之间的速度差。
-
恢复系数:碰撞前后的相对速度之比。这是对一个物体被另一个物体反弹后剩余的动能或反弹力的测量。
-
摩擦系数:两个物体之间摩擦力的比值。在你非常简单的实现中,摩擦力被直接用来减缓线性运动或旋转。
-
冲量:一段时间内积累的力,可以引起速度的变化。例如由碰撞造成的。
Note
物体的旋转由它们的角速度来描述,将在下一节中讨论。在本节的其余部分,速度一词是指物体的运动或其线速度。
碰撞中的速度分量
图 9-25 显示了三个不同阶段的圆 A。在第一阶段,圆圈以速度向右边的墙移动。在阶段 2,圆圈与墙壁碰撞,在阶段 3,圆圈被反射并以速度
离开墙壁。
图 9-25
完美世界中圆与墙的碰撞
在数学上,这种碰撞和响应可以通过将初始速度分解成垂直于和*行于碰撞壁的分量来描述。一般来说,碰撞的垂直方向称为碰撞法线
,与碰撞位置相切的方向称为碰撞切线
。这种分解可以在下面的等式中看到:
在没有摩擦和动能损失的完美世界中,碰撞不会影响切线方向上的分量,而法向分量将简单地反转。这样,反射向量可以表示为
的法向分量和切向分量的线性组合,如下所示:
注意组件前面的负号。你可以在图 9-25 中看到,由于碰撞,矢量
的
分量指向与
相反的方向。此外,注意在切线方向
,
继续指向相同的方向。这是因为切线分量*行于壁,不受碰撞的影响。这个分析通常适用于没有摩擦和动能损失的完美世界中的任何碰撞。
碰撞形状的相对速度
将向量分解为碰撞的法线方向和切线方向也可以应用于两个碰撞形状都在运动的一般情况。例如,图 9-26 显示了两个行驶圆形状 A 和 B 发生碰撞。
图 9-26
两个旅游圈之间的碰撞
在图 9-26 的情况下,碰撞前,物体 A 以速度行进,而物体 B 以速度
行进。碰撞的法线方向
被定义为两个圆心之间的矢量,而碰撞的切线方向
是在碰撞点与两个圆相切的矢量。为了解决这个碰撞,必须计算碰撞后物体 A 和 B 的速度
和
。
基于两个形状之间的相对速度来确定碰撞后的速度。形状 A 和 B 之间的相对速度定义如下:
碰撞矢量分解现在可以应用于相对速度的法线和切线方向,其中碰撞后的相对速度为:
-
【①】
-
** (2)**
恢复系数 e 和摩擦系数 f 模拟了真实世界的情况,在碰撞过程中,一些动能转变为其他形式的能量。等式(1)的负号表示碰撞后,物体将沿与初始碰撞法线方向相反的方向行进。等式(2)表明,碰撞后,摩擦力的大小将缩小,此时物体将继续以较低的速度沿相同的切线方向运动。请注意,等式(1)和(2)右侧的所有变量都已定义,因为它们在碰撞时是已知的。记住这一点很重要
其中目标是导出和
的解,即碰撞后碰撞物体的各自速度。现在,您已经准备好为*似
和
的解决方案建模了。
Note
恢复系数e
描述了碰撞后保留的反弹力或速度比例。恢复值为 1.0 意味着碰撞前后的速度相同。相比之下,摩擦与碰撞后的比例损失或减速有着直观的联系。例如,1.0 的摩擦系数意味着无限摩擦,而零速度是由碰撞产生的。为了公式的一致性,等式(2)中的系数f
实际上是 1 减去直观的摩擦系数。
冲动
准确描述碰撞涉及复杂的考虑因素,包括能量变化形式或不同材料属性产生的摩擦等因素。如果不考虑这些高级问题,对发生在一个形状上的碰撞的简单描述是一个恒定质量的物体在与另一个物体接触后,其速度从变为
。方便的是,这就是脉冲的定义,如下所示:
或者在求解时
** (3)**
记住,同样的冲量也会引起物体 B 的速度变化,只是方向相反:
或者在求解时
** (4)**
从数学上退一步,想想这个公式说明了什么。直觉上讲得通。这个等式表明速度的变化与物体的质量成反比。换句话说,一个物体的质量越大,它在碰撞后的速度变化就越小。脉冲法实现了这种观察。
回想一下,等式(1)和(2)独立地根据碰撞法向和切线方向描述了碰撞后的相对速度。作为向量的冲量也可以表示为碰撞法向和切向分量的线性组合,j N 和 j T :
将该表达式代入等式(3)和(4)得到如下结果:
-
** (5)**
-
** (6)**
注意,j N 和 j T 是这两个方程中仅有的未知数,其中其余项或者由用户定义,或者可以基于几何形状来计算。即数量、
、m A 和 m B 由用户定义,并且可以计算
和
。
Note
和
向量被归一化并且彼此垂直。由于这个原因,向量用自己点号时的值为 1,用彼此点号时的值为 0。
脉冲的正常分量
脉冲的法向分量 j N 可以通过用等式(5)和(6)两边的向量进行点积来求解:
减去前面的两个等式得到以下结果:
回想一下就是
,而
就是
,这个等式简化为:
将等式(1)代入左侧,得出以下等式:
收集各项并求解 j N ,法向冲量,结果如下:
** (7)**
脉冲的正切分量
脉冲的正切分量 j T 可以通过用等式(5)和(6)两边的向量进行点积来求解:
遵循与法向分量的情况类似的步骤,减去等式,并识别出是
并且
是
,推导出以下等式:
现在,将等式(2)代入左侧,得到如下结果:
最后,收集各项并求解 j T 或切线方向上的冲量,结果如下:
** (8)**
冲突解决项目
这个项目将指导你通过计算冲量和更新碰撞物体的速度来解决碰撞。你可以在图 9-27 中看到这个项目运行的例子。该项目的源代码在chapter9/9.7.collision_resolution
中定义。
图 9-27
运行冲突解决项目
该项目的控制与之前的项目相同,但增加了对恢复力和摩擦系数的控制:
-
行为控制:
-
P 键:切换所有物体的穿透分辨率
-
V 键:切换所有对象的运动
-
H 键:给所有物体注入随机速度
-
G 键:随机创建一个新的刚性圆或矩形
-
-
绘制控件:
-
C 键:切换所有
CollisionInfo
的绘制 -
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象。
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。 -
上下箭头键+ M/N/F :增加/减少所选对象的质量/恢复力/摩擦力。
-
该项目的目标如下:
-
为了理解脉冲方法的细节
-
为了在解决冲突中实现脉冲方法
更新物理组件
为了正确支持碰撞解决,您只需关注物理组件并修改src/engine/components
文件夹中的physics.js
文件:
- 编辑
physics.js
并定义resolveCollision()
函数来解决RigidShape
对象、a
和b
之间的碰撞,碰撞信息记录在collisionInfo
对象中:
function resolveCollision(b, a, collisionInfo) {
let n = collisionInfo.getNormal();
// Step A: Compute relative velocity
let va = a.getVelocity();
let vb = b.getVelocity();
let relativeVelocity = [0, 0];
vec2.subtract(relativeVelocity, va, vb);
// Step B: Determine relative velocity in normal direction
let rVelocityInNormal = vec2.dot(relativeVelocity, n);
// if objects moving apart ignore
if (rVelocityInNormal > 0) {
return;
}
// Step C: Compute collision tangent direction
let tangent = [0, 0];
vec2.scale(tangent, n, rVelocityInNormal);
vec2.subtract(tangent, tangent, relativeVelocity);
vec2.normalize(tangent, tangent);
// Relative velocity in tangent direction
let rVelocityInTangent = vec2.dot(relativeVelocity, tangent);
// Step D: Determine the effective coefficients
let newRestituion = (a.getRestitution() + b.getRestitution()) * 0.5;
let newFriction = 1 - ((a.getFriction() + b.getFriction()) * 0.5);
// Step E: Impulse in the normal and tangent directions
let jN = -(1 + newRestituion) * rVelocityInNormal;
jN = jN / (a.getInvMass() + b.getInvMass());
let jT = (newFriction - 1) * rVelocityInTangent;
jT = jT / (a.getInvMass() + b.getInvMass());
// Step F: Update velocity in both normal and tangent directions
vec2.scaleAndAdd(va, va, n, (jN * a.getInvMass()));
vec2.scaleAndAdd(va, va, tangent, (jT * a.getInvMass()));
vec2.scaleAndAdd(vb, vb, n, -(jN * b.getInvMass()));
vec2.scaleAndAdd(vb, vb, tangent, -(jT * b.getInvMass()));
}
列出的代码严格遵循解决方案推导过程:
-
编辑
collideShape()
以在检测到碰撞并纠正位置时调用resolveCollision()
功能: -
步骤 A 和 B :计算相对速度及其法向分量。当该法向分量为正时,它表示两个对象正在远离彼此移动,因此不需要冲突解决。
-
步骤 C :计算碰撞切线方向和相对速度的切线分量。
-
步骤 D :使用系数的*均值进行脉冲推导。注意在计算
newFriction
时减去 1,以保持与等式(2)的一致性。 -
步骤 E :按照列出的等式(7)和(8)计算脉冲的法向和切向分量。
-
步骤 F :通过以下等式(5)和(6)求解得到的速度。
function collideShape(s1, s2, infoSet = null) {
let hasCollision = false;
if ((s1 !== s2) &&
((s1.getInvMass() !== 0) || (s2.getInvMass() !== 0))) {
if (s1.boundTest(s2)) {
hasCollision = s1.collisionTest(s2, mCInfo);
if (hasCollision) {
... identical to previous code ...
positionalCorrection(s1, s2, mCInfo);
resolveCollision(s1, s2, mCInfo);
... identical to previous code ...
};
更新我的游戏以测试冲突解决
对MyGame
类的修改是微不足道的,主要是在默认情况下将运动和位置校正切换为活动状态。此外,创建的RigidShape
对象的初始随机旋转被禁用,因为此时碰撞响应不支持旋转。与往常一样,您可以参考src/my_game
文件夹中的源代码文件来了解实现细节。
观察
您应该用三种方式测试您的实现。首先,确保移动的形状碰撞和行为自然。第二,尝试改变物体的物理属性。第三,观察运动中的形状和具有无限质量的静止形状(周围的墙壁和静止的*台)之间的碰撞分辨率。记住,只考虑线速度,碰撞不会产生旋转。
现在,运行该项目,并注意到形状逐渐下降到*台和地板上,它们的运动在轻微反弹后停止。这清楚地表明欧拉积分、碰撞检测、位置校正和分辨率的基本情况都按预期运行。按 H 键激活所有形状,按 C 键显示碰撞信息。请注意游离的形状和墙壁/*台适当地相互作用,有柔和的反弹,没有明显的相互渗透。
使用左/右箭头选择一个对象,并使用 N/F 和上/下箭头键调整其恢复/摩擦系数。例如,将“恢复”调整为 1,将“摩擦力”调整为 0。现在用 H 键注入速度。请注意,该物体看起来特别有弹性,并且摩擦系数为 0,看起来沿着*台/地板滑动。可以尝试不同的系数设置,观察相应的弹跳度和滑溜度。
可以通过使用 G 键增加场景中的形状数量来测试系统的稳定性。在每次迭代期间,松弛循环计数为 15 会不断递增地将互穿的形状推开。例如,您可以使用 V 和 P 键关闭移动和位置校正,并创建多个(例如 10 到 20 个)重叠的形状。现在打开运动和位置修正,观察一个正常运行的系统。
在下一个项目中,您将改进分辨率解决方案,以考虑碰撞导致的角速度变化。
碰撞响应的角度分量
既然您已经有了具体的理解,并且已经成功地实现了线性速度碰撞响应的脉冲方法,那么是时候集成对更一般的旋转情况的支持了。在讨论细节之前,把牛顿线性力学的对应关系和旋转力学的对应关系联系起来是有帮助的。也就是说,线性位移对应于旋转,速度对应于角速度,力对应于扭矩,质量对应于转动惯量或角质量。转动惯量决定了围绕旋转轴的期望角加速度所需的扭矩。
下面的讨论集中于在冲量方法公式中整合旋转,并不试图呈现关于旋转的牛顿力学的评论。方便地,将适当的旋转整合到脉冲方法中不涉及任何新算法的推导。所需要的只是适当考虑旋转属性的脉冲响应公式。
考虑旋转的碰撞
将旋转整合到脉冲方法公式中的关键是认识到这样一个事实,即你一直在处理的线速度,例如,物体 A 的速度,实际上是该形状在其中心位置的速度。在没有旋转的情况下,该速度在整个物体中是恒定的,并且可以应用于任何位置。然而,如图 9-28 所示,当物体的运动包含角速度
时,其在位置 P
的线速度,实际上是该点与形状旋转中心的相对位置或位置矢量
的函数。
图 9-28
存在旋转时某一位置的线速度
Note
角速度是一个垂直于线速度的矢量。在这种情况下,由于线速度定义在 X/Y *面上,是 z 方向上的矢量。回想一下本章“简介”部分的讨论,第一个假设是刚性物体是质量均匀分布的连续几何体,质心位于几何体的中心。这个质心就是旋转轴的位置。为了简单起见,在您的实现中,
将被存储为一个简单的标量,表示矢量的 z 分量幅度。
图 9-29 显示了一个物体 B 以和
的线速度和角速度在位置 P 与物体 A 碰撞,现在你知道两个物体碰撞前在 P 点的线速度如下:
图 9-29
有角速度的碰撞形状
-
** (9)**
-
** (10)**
碰撞后,碰撞位置的线速度可表示如下:
-
** (11)**
-
** (12)**
其中和
,以及
和
是物体 A 和 B 在碰撞后的线速度和角速度,推导这些量的解正是本节的目标。
旋转的相对速度
回想一下上一节,物体 A 和 B 碰撞前后的相对速度定义如下:
这些速度基于方程(1)和(2)中的碰撞法向和切线方向的分量进行分析,并且为了方便起见,在下文中重新列出:
-
【①】
-
** (2)**
这些方程是在不考虑旋转的情况下推导出来的,公式假设速度在整个形状上是恒定的。为了支持旋转,这些方程必须推广,并在碰撞点 p 求解。
-
** (13)**
-
** (14)**
在这种情况下,和
是碰撞前后在碰撞位置 P 的相对速度。仍然正确的是,这些向量是由物体 A 和 B 在每个物体上的碰撞位置 P 处的碰撞之前、
和
以及之后、
和
的速度差来定义的。
-
** (15)**
-
** (16)**
现在,您已经准备好推广冲量法来支持旋转,并推导出*似线速度和角速度的解决方案:、
、
和
旋转脉冲法
继续前面章节中关于冲量法的讨论,在物体 A 和 B 发生碰撞后,冲量法通过冲量来描述它们的线速度变化,冲量通过它们相应质量的倒数来缩放,m A 和 m B 。这种线速度的变化在等式(3)和(4)中描述,重新列出如下:
-
** (3)**
-
** (4)**
一般来说,旋转是碰撞的内在结果,同样的冲量必须恰当地描述碰撞前后角速度的变化。记住惯性,或转动惯性,是转动质量。与线速度和质量类似,碰撞中角速度的变化与转动惯量成反比。如图 9-29 所示,对于转动惯量为 I A 和 I B 的物体 A 和 B,碰撞后的角速度和
可描述如下,其中
和
为各物体的位置矢量:
-
** (17)**
-
** (18)**
回想上一节,将冲量表示为碰撞法向和切向分量的线性组合和
比较方便,或者如图所示:
将该表达式代入等式(17)得到以下结果:
这样,等式(17)和(18)可以扩展为描述由脉冲的法向和切向分量引起的角速度变化,如下所示:
-
** (19)**
-
** (20)**
描述线性速度变化的相应方程,方程(5)和(6)重新列出如下:
-
** (5)**
-
** (6)**
现在,您可以将等式(5)和(19)代入等式(11),将等式(6)和(20)代入等式(12):
-
** (21)**
-
** (22)**
重要的是要重申,线速度和角速度的变化是由同一个脉冲描述的。换句话说,方程(21)和(22)中的法向和切向脉冲分量 j N 和 j T 是相同的量,并且这两个是这些方程中仅有的未知数,其中其余项是由用户定义的值或者可以基于几何形状计算的值。即数量
、
、m A 、m B 、
、
、I A 和 I B 由用户定义,并且可以计算
、
、
和
。现在你已经准备好推导 j N 和 j T 的解了。
Note
在下面的推导中,记住三重标积恒等式的定义是很重要的;这个恒等式表明,给定向量,,
,
,下列等式总是成立:
脉冲的正常分量
脉冲的法向分量 j N 可以通过假设角速度正切分量的贡献最小并且可以忽略不计,并且从等式(21)和(22)中分离出法向分量来*似计算。为清楚起见,您将一次处理一个方程,并从对象 a 的方程(21)开始。
现在,忽略角速度的切向分量,用等式(21)两边的向量进行点积,以分离法向分量:
进行右手边的点积,认识到是单位矢量,与
垂直,设
;那么,这个等式可以改写如下:
** (23)**
等式(23)中最右边一项的向量运算可以通过应用三重标量积恒等式来简化,并且记住:
=
通过这种操作和用点积收集各项,等式(23)变成如下:
根据等式(9),在右侧,具有点积的项就是:
** (24)**
方程(22)可以通过相同的代数操作步骤来处理,即忽略角速度的正切分量,并在方程的两侧执行与向量的点积;可以得出以下结论:
** (25)**
从(24)中减去等式(25)得到如下结果:
将等式(16)代入左侧的等式(13)和右侧的等式(15),可以得到以下结果:
最后,收集术语并求解 j N :
** (26)**
脉冲的正切分量
脉冲的正切分量 j T 可以通过假设角速度法向分量的贡献最小并且可以忽略不计,并且通过对等式两边的向量执行点积来分离等式(21)和(22)的正切分量来*似计算:
现在,按照精确的代数操作步骤,当使用法向分量时,切线方向上的冲量 j T 可以推导并表示如下:
** (27)**
碰撞角分辨率项目
这个项目将指导你实现支持旋转的通用碰撞脉冲响应。你可以在图 9-30 中看到这个项目运行的例子。该项目的源代码在chapter9/9.8.collision_angular_resolution
中定义。
图 9-30
运行碰撞角度分辨率项目
该项目的控件与之前的项目相同:
-
行为控制:
-
P 键:切换所有物体的穿透分辨率
-
V 键:切换所有对象的运动
-
H 键:给所有物体注入随机速度
-
G 键:随机创建一个新的刚性圆或矩形
-
-
绘制控件:
-
C 键:切换所有
CollisionInfo
的绘制 -
T 键:切换所有对象的纹理
-
R 键:切换
RigidShape
的绘制 -
B 键:切换各
RigidShape
上的装订绘图
-
-
对象控件:
-
左/右箭头键:依次选择一个对象。
-
WASD 键:移动选中的对象。
-
Z/X 键:旋转选中的对象。
-
Y/U 键:增大/减小所选对象的
RigidShape
尺寸;这不会改变相应的Renderable
对象的大小。 -
上下箭头键+ M/N/F :增加/减少所选对象的质量/恢复力/摩擦力。
-
该项目的目标如下:
-
为了理解角冲量的细节
-
将旋转整合到碰撞解决方案中
-
完成物理部分
Note
x-y *面上的线速度和沿 z 轴的角速度
、
的叉积是 x-y *面上的矢量。
更新物理组件
要正确整合角脉冲,只需替换src/engine/components
文件夹的physics.js
文件中的resolveCollision()
函数即可。虽然实现过程严格遵循代数推导步骤,但它相当漫长和复杂。为了便于理解和清楚起见,下面详细描述了实施步骤:
- 步骤 A :计算相对速度。如图 9-29 和等式(9)和(10)所示,在存在角速度的情况下,确定碰撞位置(步骤 A1)和计算碰撞位置处的线速度
和
是很重要的(步骤 A2)。
function resolveCollision(b, a, collisionInfo) {
let n = collisionInfo.getNormal();
// Step A: Compute relative velocity
... implementation to follow ...
// Step B: Determine relative velocity in normal direction
... implementation to follow ...
// Step C: Compute collision tangent direction
... implementation to follow ...
// Step D: Determine the effective coefficients
... implementation to follow ...
// Step E: Impulse in the normal and tangent directions
... implementation to follow ...
// Step F: Update velocity in both normal and tangent directions
... implementation to follow ...
}
- 步骤 B :确定法线方向的相对速度。正法线方向分量表示对象正在分开,碰撞已解决。
// Step A: Compute relative velocity
let va = a.getVelocity();
let vb = b.getVelocity();
// Step A1: Compute the intersection position p
// the direction of collisionInfo is always from b to a
// but the Mass is inverse, so start scale with a and end scale with b
let invSum = 1 / (b.getInvMass() + a.getInvMass());
let start = [0, 0], end = [0, 0], p = [0, 0];
vec2.scale(start, collisionInfo.getStart(), a.getInvMass() * invSum);
vec2.scale(end, collisionInfo.getEnd(), b.getInvMass() * invSum);
vec2.add(p, start, end);
// Step A2: Compute relative velocity with rotation components
// Vectors from center to P
// r is vector from center of object to collision point
let rBP = [0, 0], rAP = [0, 0];
vec2.subtract(rAP, p, a.getCenter());
vec2.subtract(rBP, p, b.getCenter());
// newV = V + mAngularVelocity cross R
let vAP1 = [-1 * a.getAngularVelocity() * rAP[1],
a.getAngularVelocity() * rAP[0]];
vec2.add(vAP1, vAP1, va);
let vBP1 = [-1 * b.getAngularVelocity() * rBP[1],
b.getAngularVelocity() * rBP[0]];
vec2.add(vBP1, vBP1, vb);
let relativeVelocity = [0, 0];
vec2.subtract(relativeVelocity, vAP1, vBP1);
- 步骤 C :计算碰撞切线方向和相对速度的切线方向分量。
// Step B: Determine relative velocity in normal direction
let rVelocityInNormal = vec2.dot(relativeVelocity, n);
// if objects moving apart ignore
if (rVelocityInNormal > 0) {
return;
}
- 步骤 D :使用碰撞物体的*均值确定有效系数。和前面的项目一样,为了一致性,摩擦系数是 1 减去来自
RigidShape
对象的值。
// Step C: Compute collision tangent direction
let tangent = [0, 0];
vec2.scale(tangent, n, rVelocityInNormal);
vec2.subtract(tangent, tangent, relativeVelocity);
vec2.normalize(tangent, tangent);
// Relative velocity in tangent direction
let rVelocityInTangent = vec2.dot(relativeVelocity, tangent);
- 步骤 E :法向和切线方向的冲量,这些通过精确地遵循等式(26)和(27)来计算。
// Step D: Determine the effective coefficients
let newRestituion = (a.getRestitution() + b.getRestitution()) * 0.5;
let newFriction = 1 - ((a.getFriction() + b.getFriction()) * 0.5);
- 步骤 F :更新线速度和角速度。这些更新完全遵循等式(5)、(6)、(19)和(20)。
// Step E: Impulse in the normal and tangent directions
// R cross N
let rBPcrossN = rBP[0] * n[1] - rBP[1] * n[0]; // rBP cross n
let rAPcrossN = rAP[0] * n[1] - rAP[1] * n[0]; // rAP cross n
// Calc impulse scalar, formula of jN
// can be found in http://www.myphysicslab.com/collision.html
let jN = -(1 + newRestituion) * rVelocityInNormal;
jN = jN / (b.getInvMass() + a.getInvMass() +
rBPcrossN * rBPcrossN * b.getInertia() +
rAPcrossN * rAPcrossN * a.getInertia());
let rBPcrossT = rBP[0] * tangent[1] - rBP[1] * tangent[0];
let rAPcrossT = rAP[0] * tangent[1] - rAP[1] * tangent[0];
let jT = (newFriction - 1) * rVelocityInTangent;
jT = jT / (b.getInvMass() + a.getInvMass() +
rBPcrossT * rBPcrossT * b.getInertia() +
rAPcrossT * rAPcrossT * a.getInertia());
// Update linear and angular velocities
vec2.scaleAndAdd(va, va, n, (jN * a.getInvMass()));
vec2.scaleAndAdd(va, va, tangent, (jT * a.getInvMass()));
setAngularVelocityDelta((rAPcrossN * jN * a.getInertia() +
rAPcrossT * jT * a.getInertia()));
vec2.scaleAndAdd(vb, vb, n, -(jN * b.getInvMass()));
vec2.scaleAndAdd(vb, vb, tangent, -(jT * b.getInvMass()));
b.setAngularVelocityDelta(-(rBPcrossN * jN * b.getInertia() +
rBPcrossT * jT * b.getInertia()));
观察
运行项目以测试您的实现。现在,您插入到场景中的形状会以类似于真实世界的方式旋转、碰撞和响应。当其他形状与圆形碰撞时,圆形会滚动,而矩形在碰撞时会自然旋转。形状之间的相互渗透在正常情况下应该是不可见的。然而,两种情况仍然可能导致可观察到的相互渗透:首先,一个小的松弛迭代,或者第二,你的 CPU 正在与形状的数量作斗争。在第一种情况下,您可以尝试增加松弛迭代来防止任何渗透。
使用旋转支持,现在可以检查碰撞中质量差异的影响。由于它们滚动的能力,圆之间的碰撞是最容易观察到的。等待所有对象静止,使用箭头键选择一个已创建的圆;键入带有向上箭头的 M 键,将其质量增加到一个较大的值,例如 20。现在选择另一个物体,使用 WASD 键移动并放下选中的物体到大质量圆上。请注意,大质量圈在碰撞中没有太大的运动。例如,一次碰撞很可能甚至不会导致大质量圈滚动。现在,键入 H 键将随机速度注入所有对象,并观察碰撞。请注意,与大质量圈的碰撞几乎就像与静止的墙壁/*台的碰撞。用冲量法模拟的反演质量和转动惯量能够成功地捕捉不同质量物体的碰撞效应。
现在,您的 2D 物理引擎实现已经完成。您可以通过创建额外的形状来继续测试,以观察您的 CPU 何时开始努力保持实时性能。
摘要
这一章已经引导你理解了物理引擎背后的基础。通过仅关注刚体交互或刚体形状模拟,现实世界中对象的复杂物理交互被大大简化。模拟过程假设对象是质量均匀分布的连续几何体,它们的形状在碰撞过程中不会改变。计算成本高的模拟仅在由简单的圆形和矩形*似的对象的选定子集上执行。
一步一步的推导出相关的模拟公式,然后是一个详细的指导,以建立一个功能系统。您已经学会了提取形状之间的碰撞信息,基于分离轴定理制定和计算形状碰撞,使用辛欧拉积分*似牛顿运动积分,基于数值稳定的渐进松弛解决碰撞对象的相互渗透,以及基于脉冲方法推导和实现碰撞解决。
现在您已经完成了您的物理引擎,您可以仔细检查系统,并确定优化和进一步抽象的潜力。对物理引擎的许多改进仍然是可能的。从用新定义的强大功能支持游戏开发者的角度来看,尤其如此。例如,大多数物理引擎也支持没有任何响应的直接碰撞检测。这是你的物理组件中缺失的一个重要功能。虽然您的引擎能够模拟碰撞结果,但该引擎不支持对简单且计算成本低得多的对象是否碰撞的问题做出响应。如上所述,这可能是一个很好的练习。
虽然简单,但缺少一些方便的界面功能,您的物理组件在功能上是完整的,能够模拟刚性形状的交互,具有视觉上令人愉快和真实的结果。您的系统支持直观的参数,包括对象质量、加速度、速度、恢复力和摩擦力,这些参数可能与现实世界中对象的行为相关。尽管计算要求很高,但您的系统能够支持大量的刚性形状交互。如果游戏类型只需要一个或一小组,例如,英雄和友好角色,与其余的对象(例如,道具、*台和敌人)交互,情况尤其如此。
游戏设计注意事项
到目前为止,示例中的难题级别完全集中在创建一个可理解且一致的逻辑挑战上;我们已经避免了任何视觉设计、叙事或虚构场景(传统上与增强玩家存在感相关的设计元素)给练习带来负担,以确保我们只考虑游戏规则,而不会引入干扰。然而,当你创建核心游戏机制时,重要的是理解游戏的某些元素是如何直接影响临场感的;核心游戏机制的逻辑规则和要求通常对临场感的影响有限,除非它们与交互模型、声音和视觉设计以及设置相匹配。正如在第八章中所讨论的,照明是增强临场感的视觉设计元素的一个例子,它也可以直接用作游戏的核心机制,将物理引入游戏世界的物体也是一种增强临场感的技术,甚至可能更经常地与游戏直接相关。
我们在现实世界中的经验受物理学支配,因此在游戏中引入类似的行为有望增强存在感,这是显而易见的。增强临场感但不一定有助于游戏机制的对象物理的一个例子可以是对游戏性没有直接影响的可破坏环境:例如,在第一人称射击游戏中,如果玩家射击板条箱和其他游戏对象,这些对象在受到冲击时会真实地爆炸,或者如果他们在游戏世界中投掷一个球,该球在物理世界中的反弹方式与球在物理世界中的反弹方式相当*似,这些是纯粹用于增强临场感但不一定有助于游戏性的物理的例子。然而,如果一名玩家正在玩一款类似愤怒的小鸟的游戏,并从弹弓中将其中一只小鸟发射到游戏空间,他们需要根据小鸟发射时遵循的物理模型抛物线来计时射击(如图 9-31 所示),这是一个物理被用作游戏性的核心元素同时也增强临场感的例子。事实上,任何涉及在模拟重力环境中跳跃角色或其他游戏对象的游戏都是物理对在场和核心机制做出贡献的例子,因此许多*台游戏利用物理作为核心机制和增强在场的设计元素。
图 9-31
Rovio 的《愤怒的小鸟》要求玩家在虚拟世界中从弹弓中发射射弹,该虚拟世界对重力、质量、动量和物体碰撞检测进行建模。游戏物理是游戏机制的基本组成部分,它通过赋予虚拟物体物理世界的特征来增强临场感
第九章中的项目向你介绍了物理学将玩家带入游戏世界的强大能力。玩家现在可以体验模拟的惯性、动量和重力,而不是简单地像移动屏幕光标一样移动英雄角色,这需要在操纵物理世界中的对象时对瞄准、时机和前进轨迹进行同样的预测评估,并且游戏对象现在能够以我们熟悉的物理世界体验的方式碰撞。即使特定值可能在模拟游戏空间中偏离真实世界(例如,较低或较高的重力、或多或少的惯性等),只要关系是一致的并且合理地模拟我们的物理体验,当这些效果被添加到游戏对象时,存在感通常会增加。想象一下,例如,在一个游戏关卡中,主角需要在指定的时间限制内将所有的机器人推到一个特定的区域,同时避免被射弹击中。想象一下没有物理的相同水*,这当然会是一种非常不同的体验。
我们在第八章中留下了关卡设计,其中有一个有趣的两阶段机制,几乎完全专注于抽象的逻辑规则,还没有加入将增加体验的存在并将玩家带入游戏世界的元素。回忆图 9-32 中的当前水*状态。
图 9-32
目前的关卡包括两步拼图,首先要求玩家移动手电筒并显示隐藏的符号;然后,玩家必须按正确的顺序激活图形,以解锁关卡并获得奖励
当然,当前关卡设计传达了一些存在感:阻止玩家获得奖励的屏障是“不可穿透的”,由一堵虚拟墙表示,手电筒对象“照射”虚拟光束,以某种方式揭示隐藏的线索,也许现实世界中的紫外光可以揭示特殊的墨水。坦白地说,在开发的这个阶段,存在感是很弱的,然而,因为我们还没有将游戏体验放在一个环境中,有意设计的通用形状并没有提供多少帮助玩家建立他们自己的内部叙事。我们当前的原型使用类似手电筒的游戏对象来显示隐藏的符号,但现在可以将游戏机制的逻辑规则从当前的实现中分离出来,并将核心游戏机制描述为“玩家必须探索环境,以找到以正确顺序组装序列所需的工具。”
在我们游戏的下一次迭代中,让我们重新审视交互模型,把它从纯粹的逻辑谜题进化成一个更加活跃的利用物体物理的东西。图 9-33 改变游戏画面以包括跳跃组件。
图 9-33
游戏屏幕现在只显示锁的每个部分(顶部、中部、底部)的一个实例,主角以传统的跳跃 2D *台玩家的方式移动。左右六个*台是静止的,中间的*台上下移动,让玩家可以提升到更高的层次。(这张图片假设玩家能够在同一层的*台之间“跳跃”英雄角色,但是如果不使用移动的*台就无法到达更高的层。)
我们现在正在发展游戏,以包括灵活性挑战——在这种情况下,对跳跃进行计时——但它保留了早期迭代中相同的逻辑规则:形状必须以正确的顺序激活,以解锁阻止奖励的障碍。想象玩家第一次体验这个屏幕;他们将开始探索屏幕,以了解该关卡的参与规则,包括交互模型(用于移动和跳跃英雄角色的按键和/或鼠标按钮),错过一次跳跃是否会导致惩罚(例如,如果英雄角色错过一次跳跃并从游戏屏幕上掉下,将失去一条“生命”),以及“激活”一个形状并开始解锁障碍的序列意味着什么。
游戏现在有了一个有趣的(虽然仍然是基本的)*台谜题的开始,但与我们早期的迭代相比,我们现在也简化了解决方案,*台跳跃组件并不特别具有挑战性,如图 9-33 所示。回想一下在第八章中添加手电筒是如何通过增加第二种挑战来增加原始机械师的逻辑挑战的,这种挑战要求玩家识别并使用环境中的一个物体作为工具;我们可以给*台玩家组件添加一个类似的第二个挑战,如图 9-34 所示。
图 9-34
引入力场阻止上层*台(#1)的进入会显著增加*台组件的挑战。在这个设计中,玩家必须激活开关(在#2 中用灯泡表示)来禁用力场并到达第一个和第三个形状
力场的引入开启了各种有趣的可能性来增加挑战。玩家必须在击中力场之前计算从移动*台跳到开关的时间,并且必须按顺序激活形状(要求玩家首先激活右上,然后右下,然后左上)。想象一下,当开关被扳动时,停用被设置了一个时间限制,并且如果在力场被重新激活之前所有的形状没有被激活,拼图将被重置。
我们现在已经采用了基于逻辑顺序的基本机制,并对其进行了修改以支持动作*台玩家体验。在这个发展阶段,这个机制变得越来越有趣,开始感觉更像一个可玩的关卡,但它仍然缺乏设定和背景;这是一个很好的机会来探索我们可能想用这个游戏来讲述的故事。我们对科幻冒险感兴趣吗,也许是生存恐怖经历,也许是一系列没有关联叙事的谜题关卡?该设置不仅有助于传达游戏的视觉特征,还可以指导我们为玩家创造的各种挑战的决策(例如,游戏中的“敌人”是否对玩家不利,游戏是否会继续专注于解决逻辑难题,或者两者兼而有之?).练习将游戏机制与一个场景联系起来的一个很好的练习是选择一个地方(例如,一艘宇宙飞船的内部),开始探索这个虚拟空间中的游戏,并以一种对场景有意义的方式定义挑战的元素。对于一个宇宙飞船上的游戏来说,可能会出现一些问题,玩家必须从飞船的一端走到另一端,同时通过巧妙利用环境物体来抵消安全激光。尝试将飞船设置应用于当前的游戏机制,并调整关卡中的元素以适应该主题:激光只是一个选项,但你能想到我们的游戏机制的其他用途,而不涉及解锁序列吗?尝试将游戏机制应用到一系列不同的环境中,开始将抽象的游戏应用到特定的环境中。
还要记住,在关卡设计中包含物体物理并不总是创造一个伟大游戏的必要条件;有时候你可能想要颠覆或者完全忽略你创造的游戏世界中的物理定律。你的游戏体验的最终质量是你如何有效地协调和*衡游戏设计的九个元素的结果;它不是关于任何一个设计选项的强制实现。你的游戏可能是完全抽象的,涉及的形状和形式在空间中以一种与物理世界无关的方式变化,但你对颜色、音频和叙事的使用可能仍然会结合起来,为玩家创造一种强烈的体验。然而,如果你发现自己在一个游戏环境中,试图通过利用人们将在物理世界中发现的事物联系起来的物体来传达一种物理感,那么探索物体物理学如何增强这种体验是值得的。
十、使用粒子系统创建效果
完成本章后,您将能够
-
理解粒子、粒子发射器和粒子系统的基础知识
-
意识到许多有趣的物理效果可以基于专用粒子的集合来建模
-
*似粒子的基本行为,使得这些粒子集合的再现类似于简单的爆炸效果
-
实现一个简单的粒子系统,它与物理组件的
RigidShape
系统集成在一起
介绍
到目前为止,在您的游戏引擎中,假设游戏世界可以由一组几何图形来描述,其中所有对象都是具有纹理或动画精灵的Renderable
实例,并且可能被光源照亮。这个游戏引擎非常强大,能够描述现实世界中的大部分物体。然而,对你的游戏引擎来说,描述许多日常遭遇也是具有挑战性的,例如,火花、火、爆炸、污垢、灰尘等等。这些观测中有许多是由改变物理状态的物质或对物理扰动作出反应的非常小的实体的集合产生的瞬时效应。总的来说,这些观察结果通常被称为特殊效果,通常不适合用带有纹理的固定形状的几何图形来表示。
粒子系统通过发射一组粒子来描述特殊效果,这些粒子的属性可能包括位置、大小、颜色、寿命和策略选择的纹理贴图。这些粒子定义了特定的行为,一旦发射,它们的属性就会更新以模拟物理效果。例如,发射的火粒子可能会向上移动并带有红色。随着时间的推移,粒子的大小可能会减小,向上运动的速度会变慢,颜色会变黄,并在一定次数的更新后最终消失。通过精心设计的更新功能,这种粒子集合的再现可以类似于燃烧的火焰。
在本章中,您将学习、设计和创建一个简单而灵活的粒子系统,该系统包括实现常见效果(如爆炸和魔法效果)所需的基本功能。此外,您将实现一个粒子着色器,以正确地将粒子集成到场景中。粒子会相应地与RigidShape
物体发生碰撞和相互作用。您还将发现需要并定义粒子发射器,以在一段时间内生成粒子,如篝火或火炬。
本章的主要目标是理解粒子系统的基础:简单粒子的属性和行为,粒子发射器的细节,以及与游戏引擎其余部分的集成。这一章不会引导你创建任何特定类型的特效。这类似于在第八章中学习一个照明模型,没有创建任何灯光效果的细节。操纵光源参数和材料属性以创建引人入胜的照明条件,以及模拟特定物理效果的粒子行为是游戏开发人员的职责。游戏引擎的基本职责是定义足够的基本功能,以确保游戏开发者能够完成他们的工作。
粒子和粒子系统
粒子是一个没有维度的纹理位置。这种描述可能看起来矛盾,因为你已经知道纹理是一种图像,图像总是由宽度和高度定义,并且肯定会占用一个区域。重要的澄清是,游戏引擎逻辑将粒子处理为没有区域的位置,而绘图系统将粒子显示为具有适当尺寸的纹理。这样,即使显示了实际显示的区域,纹理的宽度和高度尺寸也会被底层逻辑忽略。
除了位置,粒子还具有大小(用于缩放纹理)、颜色(用于给纹理着色)和寿命等属性。与典型的游戏对象类似,每个粒子都定义有在每次更新期间修改其属性的行为。这个更新函数的责任是确保粒子集合的再现类似于熟悉的物理效果。粒子系统是控制每个粒子生成、更新和移除的实体。在你的游戏引擎中,粒子系统将被定义为一个独立的组件,就像物理组件一样。
在以下项目中,您将首先了解绘制粒子对象所需的支持。之后,您将研究如何创建实际粒子对象并定义其行为的细节。粒子是游戏引擎的一种新型对象,需要整个绘图系统的支持,包括自定义 GLSL 着色器、默认可共享着色器实例和一个新的Renderable
对。
粒子项目
这个项目演示了如何实现一个粒子系统来模拟爆炸或类似法术的效果。你可以在图 10-1 中看到这个项目运行的例子。这个项目的源代码位于chapter10/10.1.particles
文件夹中。
图 10-1
运行粒子项目
这个项目是上一章的延续,支持所有的刚性形状和碰撞控制。为简洁起见,本章不再重复这些控制的细节。项目的粒子系统特定控制如下:
-
Q 键:在当前鼠标位置产生粒子
-
E 键:切换粒子边界的绘制
该项目的目标如下:
-
要了解如何绘制粒子并定义其行为的细节
-
实现一个简单的粒子系统
您可以在assets
文件夹中找到以下外部资源:包含默认系统字体的fonts
文件夹,包含particle.png
的粒子文件夹,默认粒子纹理,以及之前项目中相同的四个纹理图像。
-
定义英雄和小兵的精灵元素。
-
platform.png
定义*台、地板和天花板。 -
wall.png
定义墙壁。 -
target.png
标识当前选择的对象。
支持粒子的绘制
粒子是没有区域的纹理位置。然而,正如介绍中所讨论的,你的引擎会将每个粒子绘制成一个纹理矩形。出于这个原因,你可以简单地重用现有的纹理顶点着色器texture_vs.glsl
。
创建 GLSL 粒子片段着色器
当涉及到每个像素颜色的实际计算时,必须创建一个新的 GLSL 片段着色器particle_fs.glsl
,以忽略全局环境项。火焰和爆炸等物理效果不参与照明计算。
-
在
src/glsl_shaders
文件夹下,新建一个文件,命名为particle_fs.glsl
。 -
类似于在
texture_fs.glsl
中定义的纹理片段着色器,您需要声明uPixelColor
和vTexCoord
来从游戏引擎接收这些值,并定义uSampler
来采样纹理: -
现在实现 main 函数来累积颜色,不考虑全局环境效果。这是计算粒子颜色的一种方法。这个函数可以被修改以支持不同种类的粒子效果。
precision mediump float;
// sets the precision for floating point computation
// The object that fetches data from texture.
// Must be set outside the shader.
uniform sampler2D uSampler;
// Color of pixel
uniform vec4 uPixelColor;
// "varying" signifies that the texture coordinate will be
// interpolated and thus varies.
varying vec2 vTexCoord;
void main(void) {
// texel color look up based on interpolated UV value in vTexCoord
vec4 c = texture2D(uSampler, vec2(vTexCoord.s, vTexCoord.t));
vec3 r = vec3(c) * c.a * vec3(uPixelColor);
vec4 result = vec4(r, uPixelColor.a);
gl_FragColor = result;
}
定义默认 ParticleShader 实例
现在,您可以定义要共享的默认粒子着色器实例。回想在前面章节中使用其他类型的着色器时,着色器创建一次,并在src/engine/core
文件夹的shader_resoruces.js
文件中共享引擎范围。
-
首先编辑
src/engine/core
文件夹中的shader_resources.js
文件,为默认粒子着色器定义常量、变量和访问函数: -
在
init()
函数中,确保加载新定义的particle_fs
GLSL 片段着色器:
// Particle Shader
let kParticleFS = "src/glsl_shaders/particle_fs.glsl";
let mParticleShader = null;
function getParticleShader() { return mParticleShader }
- 正确加载新的 GLSL 片段着色器
particle_fs
,当调用createShaders()
函数时,可以实例化一个新的粒子着色器:
function init() {
let loadPromise = new Promise(
async function(resolve) {
await Promise.all([
... identical to previous code ...
text.load(kShadowReceiverFS),
text.load(kParticleFS)
]);
resolve();
}).then(
function resolve() { createShaders(); }
);
map.pushPromise(loadPromise);
}
- 在
cleanUp()
功能中,记得执行正确的清理和卸载操作:
function createShaders() {
... identical to previous code ...
mShadowReceiverShader = new SpriteShader(kTextureVS,
kShadowReceiverFS);
mParticleShader = new TextureShader(kTextureVS, kParticleFS);
}
- 最后,不要忘记导出新定义的函数:
function cleanUp() {
... identical to previous code ...
mShadowCasterShader.cleanUp();
mParticleShader.cleanUp();
... identical to previous code ...
text.unload(kShadowReceiverFS);
text.unload(kParticleFS);
}
export {init, cleanUp,
getConstColorShader, getTextureShader,
getSpriteShader, getLineShader,
getLightShader, getIllumShader,
getShadowReceiverShader, getShadowCasterShader,
getParticleShader}
创建粒子可渲染对象
使用定义为 GLSL particle_fs
着色器接口的默认粒子着色器类,您现在可以创建新的Renderable
对象类型来支持粒子的绘制。幸运的是,一个粒子或者一个纹理位置的详细行为与一个TextureRenderable
是相同的,除了不同的着色器。因此,ParticleRenderable
对象的定义是琐碎的。
在src/engine/renderables
文件夹中,创建particle_renderable.js
文件;从defaultShaders
导入以访问粒子着色器,从TextureRenderable
导入以访问基类。将ParticleRenderable
定义为TextureRenderable
的子类,并在构造函数中设置合适的默认着色器。记得导出类。
import * as defaultShaders from "../core/shader_resources.js";
import TextureRenderable from "./texture_renderable.js";
class ParticleRenderable extends TextureRenderable {
constructor(myTexture) {
super(myTexture);
this._setShader(defaultShaders.getParticleShader());
}
}
export default ParticleRenderable;
加载默认粒子纹理
为了绘制时的方便,游戏引擎会预加载默认的粒子纹理particle.png
,位于assets/particles
文件夹。该操作可以作为defaultResources
初始化过程的一部分。
-
在
src/engine/resources
文件夹中编辑default_resources.js
,从texture.js
添加一个导入来访问纹理加载功能,并为粒子纹理贴图的位置定义一个常量字符串和这个字符串的一个访问器: -
在
init()
函数中,调用texture.load()
函数加载默认的粒子纹理贴图:
import * as font from "./font.js";
import * as texture from "../resources/texture.js";
import * as map from "../core/resource_map.js";
// Default particle texture
let kDefaultPSTexture = "assets/particles/particle.png";
function getDefaultPSTexture() { return kDefaultPSTexture; }
- 在
cleanUp()
功能中,确保卸载默认纹理:
function init() {
let loadPromise = new Promise(
async function (resolve) {
await Promise.all([
font.load(kDefaultFont),
texture.load(kDefaultPSTexture)
]);
resolve();
})
... identical to previous code ...
}
- 最后,记住导出访问器:
function cleanUp() {
font.unload(kDefaultFont);
texture.unload(kDefaultPSTexture);
}
export {
... identical to previous code ...
getDefaultFontName, getDefaultPSTexture,
... identical to previous code ...
}
通过这种集成,默认的粒子纹理文件将在系统初始化期间加载到resource_map
中。这个默认的纹理贴图可以很容易地用从getDefaultPSTexture()
函数返回的值来访问。
定义引擎粒子组件
定义了绘图基础结构后,现在可以定义引擎组件来管理粒子系统的行为。目前,唯一需要的功能是包括所有粒子的默认系统加速。
在src/engine/components
文件夹中,创建particle_system.js
文件,并为默认粒子系统加速定义变量、getter 和 setter 函数。记得导出新定义的功能。
let mSystemAcceleration = [30, -50.0];
function getSystemAcceleration() {
return vec2.clone(mSystemAcceleration); }
function setSystemAcceleration(x, y) {
mSystemAcceleration[0] = x;
mSystemAcceleration[1] = y;
}
export {getSystemAcceleration, setSystemAcceleration}
在继续之前,请确保更新引擎访问文件index.js
,以允许游戏开发者访问新定义的功能。
定义粒子和粒子游戏类
现在,您已经准备好定义实际的粒子、其默认行为以及粒子集合的类。
创建粒子
粒子是轻量级的游戏对象,具有简单的属性,缠绕在ParticleRenderable
周围进行绘制。为了恰当地支持运动,粒子也用辛欧拉积分实现运动*似。
-
首先在
src/engine
文件夹中创建particles
子文件夹。该文件夹将包含特定于粒子的实现文件。 -
在
src/engine/particles
文件夹中,创建particle.js
,并定义构造函数以包含用于调试的位置、速度、加速度、阻力和绘图参数的变量: -
定义
draw()
函数将粒子绘制为TextureRenderable
和drawMarker()
调试函数在粒子位置绘制一个 X 标记:
import * as loop from "../core/loop.js";
import * as particleSystem from "../components/particle_system.js";
import ParticleRenderable from "../renderables/particle_renderable.js";
import * as debugDraw from "../core/debug_draw.js";
let kSizeFactor = 0.2;
class Particle {
constructor(texture, x, y, life) {
this.mRenderComponent = new ParticleRenderable(texture);
this.setPosition(x, y);
// position control
this.mVelocity = vec2.fromValues(0, 0);
this.mAcceleration = particleSystem.getSystemAcceleration();
this.mDrag = 0.95;
// Color control
this.mDeltaColor = [0, 0, 0, 0];
// Size control
this.mSizeDelta = 0;
// Life control
this.mCyclesToLive = life;
}
... implementation to follow ...
}
export default Particle;
- 您现在可以实现
update()
函数来计算基于辛欧拉积分的粒子位置,其中使用mDrag
变量的缩放模拟粒子上的阻力。请注意,该函数还对其他参数(包括颜色和大小)执行增量更改。mCyclesToLive
变量通知粒子系统何时该移除这个粒子。
draw(aCamera) {
this.mRenderComponent.draw(aCamera);
}
drawMarker(aCamera) {
let size = this.getSize();
debugDraw.drawCrossMarker(aCamera, this.getPosition(),
size[0] * kSizeFactor, [0, 1, 0, 1]);
}
- 定义简单的
get
和set
访问器。这些函数很简单,这里没有列出。
update() {
this.mCyclesToLive--;
let dt = loop.getUpdateIntervalInSeconds();
// Symplectic Euler
// v += a * dt
// x += v * dt
let p = this.getPosition();
vec2.scaleAndAdd(this.mVelocity,
this.mVelocity, this.mAcceleration, dt);
vec2.scale(this.mVelocity, this.mVelocity, this.mDrag);
vec2.scaleAndAdd(p, p, this.mVelocity, dt);
// update color
let c = this.mRenderComponent.getColor();
vec4.add(c, c, this.mDeltaColor);
// update size
let xf = this.mRenderComponent.getXform();
let s = xf.getWidth() * this.mSizeDelta;
xf.setSize(s, s);
}
创建粒子集
为了处理一组粒子,你现在可以创建ParticleSet
来支持方便的Particle
循环。出于轻量级的目的,Particle
类没有从更复杂的GameObject
派生出子类;然而,由于 JavaScript 是一种非类型化的语言,ParticleSet
仍然有可能继承并提炼GameObjectSet
来利用现有的特定于集合的功能。
-
在
src/engine/particles
文件夹中,创建particle_set.js
,并将ParticleSet
定义为GameObjectSet
的子类: -
覆盖
GameObjectSet
的draw()
功能,以确保使用添加剂混合绘制粒子:
import * as glSys from "../core/gl.js";
import GameObjectSet from "../game_objects/game_object_set.js";
class ParticleSet extends GameObjectSet {
constructor() {
super();
}
... implementation to follow ...
}
export default ParticleSet;
Note
回想一下第五章,默认的gl.blendFunc()
设置通过根据阿尔法通道值混合来实现透明度。这被称为阿尔法混合。在这种情况下,gl.blendFunc()
设置只是累积颜色,而不考虑 alpha 通道。这被称为添加剂混合。加法混合通常会导致像素颜色过饱和,即 RGB 分量的值大于最大显示值 1.0。当模拟火焰和爆炸的强烈亮度时,像素颜色的过饱和通常是可取的。
- 覆盖
update()
功能,确保清除过期颗粒:
draw(aCamera) {
let gl = glSys.get();
gl.blendFunc(gl.ONE, gl.ONE); // for additive blending!
super.draw(aCamera);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// restore alpha blending
}
drawMarkers(aCamera) {
let i;
for (i = 0; i < this.mSet.length; i++) {
this.mSet[i].drawMarker(aCamera);
}
}
update() {
super.update();
// Cleanup Particles
let i, obj;
for (i = 0; i < this.size(); i++) {
obj = this.getObjectAt(i);
if (obj.hasExpired()) {
this.removeFromSet(obj);
}
}
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
测试粒子系统
测试应该验证两个主要目标。首先,实现的粒子系统能够产生视觉上令人愉快的效果。第二,粒子被正确地处理,被正确地创建、破坏,并表现出预期的行为。这个测试用例主要基于之前的项目,带有一个新的_createParticle()
函数,当 Q 键被按下时,这个函数被调用。在my_game_main.js
文件中实现的_createParticle()
函数创建具有伪随机行为的粒子,如下所示:
function _createParticle(atX, atY) {
let life = 30 + Math.random() * 200;
let p = new engine.Particle(
engine.defaultResources.getDefaultPSTexture(),
atX, atY, life);
p.setColor([1, 0, 0, 1]);
// size of the particle
let r = 5.5 + Math.random() * 0.5;
p.setSize(r, r);
// final color
let fr = 3.5 + Math.random();
let fg = 0.4 + 0.1 * Math.random();
let fb = 0.3 + 0.1 * Math.random();
p.setFinalColor([fr, fg, fb, 0.6]);
// velocity on the particle
let fx = 10 - 20 * Math.random();
let fy = 10 * Math.random();
p.setVelocity(fx, fy);
// size delta
p.setSizeDelta(0.98);
return p;
}
关于_createParticle()
函数有两个重要的观察结果。首先,多次使用random()
函数来配置每个创建的Particle
。粒子系统利用大量相似但略有不同的粒子来构建和传达所需的视觉效果。使用随机性来避免任何模式是很重要的。第二,配置中使用了很多看似任意的数字,比如将粒子的寿命设置在 30 到 230 之间,或者将最终的红色分量设置为 3.5 到 4.5 之间的数字。不幸的是,这就是使用粒子系统的本质。经常有相当多的临时实验。商业游戏引擎通常通过发布其粒子系统的预设值集合来缓解这一困难。这样,游戏设计者可以通过调整提供的预设来微调特定的期望效果。
观察
运行项目,按 Q 键观察生成的粒子。看起来好像有燃烧发生在鼠标指针下面。按住 Q 键并慢慢移动鼠标指针来观察燃烧,就好像在鼠标下面有一个引擎产生火焰一样。键入 E 键来切换单个粒子位置的绘制。现在,您可以观察到一个绿色的 X 标记了每个生成粒子的位置。
如果您快速移动鼠标指针,您可以观察到带有绿色 X 中心的单个粉红色圆圈在向地板下落时会改变颜色。虽然所有粒子都是由_createParticle()
函数创建的,并且在改变颜色时都具有类似的向地板下落的行为,但是每个粒子看起来都略有不同,并且没有表现出任何行为模式。现在,您可以清楚地观察到在创建的粒子中整合随机性的重要性。
修改_createParticle()
函数的方式有无限多种。例如,只需将初始和最终颜色更改为不同的灰色和透明度,就可以将类似爆炸的效果更改为蒸汽或烟雾。此外,可以通过反转颜色来修改默认粒子纹理,以创建黑烟效果。您也可以将大小变化增量修改为大于 1,以随着时间的推移增加粒子的大小。事实上,粒子的产生是没有限制的。您实现的粒子系统允许游戏开发者创建具有自定义行为的粒子,这些行为最适合他们正在构建的游戏。
最后,请注意生成的粒子不与RigidShape
对象交互,看起来好像粒子被绘制在游戏场景中的其余对象上。这个问题将在下一个项目中研究和解决。
粒子碰撞
将粒子整合到游戏场景中的方法是让粒子遵循场景的隐含规则,并相应地与非粒子对象进行交互。检测碰撞的能力是物体之间交互的基础。出于这个原因,有时支持粒子与其他非粒子游戏对象的碰撞是很重要的。
由于粒子仅由它们的位置定义,没有维度,实际的碰撞计算可能相对简单。然而,通常有大量的粒子;同样地,要执行的冲突的数量也可以是很多的。作为计算成本的折衷和优化,粒子碰撞可以基于RigidShape
而不是实际的Renderable
对象。这类似于物理组件的情况,其中实际模拟是基于简单的刚性形状来*似潜在的几何复杂的Renderable
物体。
粒子碰撞项目
这个项目演示了如何实现一个粒子碰撞系统,它能够解决粒子和现有的RigidShape
对象之间的碰撞。你可以在图 10-2 中看到这个项目运行的例子。这个项目的源代码位于chapter10/10.2.particle_collisions
文件夹中。
图 10-2
运行粒子碰撞项目
该项目的控件与上一个项目相同,并支持所有刚性形状和碰撞控件。特定于粒子系统的控件如下:
-
Q 键:在当前鼠标位置产生粒子
-
E 键:切换粒子边界的绘制
-
1 键:切换
Particle
/RigidShape
碰撞
该项目的目标如下:
-
了解并解决单个粒子位置和
RigidShape
对象之间的碰撞 -
构建支持与
RigidShape
交互的粒子引擎组件
修改粒子系统
有了设计良好的基础设施,新功能的实现可以本地化。在粒子碰撞的情况下,所有的修改都在src/engine/components
文件夹的particle_system.js
文件中。
-
编辑
particle_system.js
定义并初始化临时局部变量,以解决与RigidShape
对象的冲突。mCircleCollider
物体将被用来代表碰撞中的单个粒子。 -
定义
resolveCirclePos()
函数,通过将位置推到圆形之外来解决RigidCircle
和位置之间的冲突:
import Transform from "../utils/transform.js";
import RigidCircle from "../rigid_shapes/rigid_circle.js";
import CollisionInfo from "../rigid_shapes/collision_info.js";
let mXform = null; // for collision with rigid shapes
let mCircleCollider = null;
let mCollisionInfo = null;
let mFrom1to2 = [0, 0];
function init() {
mXform = new Transform();
mCircleCollider = new RigidCircle(mXform, 1.0);
mCollisionInfo = new CollisionInfo();
}
- 定义
resolveRectPos()
函数,通过将mCircleCollider
局部变量包裹在位置周围并调用RigidCircle
到RigidRectangle
碰撞函数来解决RigidRectangle
和位置之间的碰撞。当检测到穿插时,根据计算出的mCollisionInfo
,该位置被推到矩形形状之外。
function resolveCirclePos(circShape, particle) {
let collision = false;
let pos = particle.getPosition();
let cPos = circShape.getCenter();
vec2.subtract(mFrom1to2, pos, cPos);
let dist = vec2.length(mFrom1to2);
if (dist < circShape.getRadius()) {
vec2.scale(mFrom1to2, mFrom1to2, 1/dist);
vec2.scaleAndAdd(pos, cPos, mFrom1to2, circShape.getRadius());
collision = true;
}
return collision;
}
- 实现
resolveRigidShapeCollision()
和resolveRigidShapeSetCollision()
以方便客户端游戏开发者调用。这些函数解决单个或一组RigidShape
对象与ParticleSet
对象之间的碰撞。
function resolveRectPos(rectShape, particle) {
let collision = false;
let s = particle.getSize();
let p = particle.getPosition();
mXform.setSize(s[0], s[1]); // referred by mCircleCollision
mXform.setPosition(p[0], p[1]);
if (mCircleCollider.boundTest(rectShape)) {
if (rectShape.collisionTest(mCircleCollider, mCollisionInfo)) {
// make sure info is always from rect towards particle
vec2.subtract(mFrom1to2,
mCircleCollider.getCenter(), rectShape.getCenter());
if (vec2.dot(mFrom1to2, mCollisionInfo.getNormal()) < 0)
mCircleCollider.adjustPositionBy(
mCollisionInfo.getNormal(), -mCollisionInfo.getDepth());
else
mCircleCollider.adjustPositionBy(
mCollisionInfo.getNormal(), mCollisionInfo.getDepth());
p = mXform.getPosition();
particle.setPosition(p[0], p[1]);
collision = true;
}
}
return collision;
}
- 最后,记住导出新定义的函数:
// obj: a GameObject (with potential mRigidBody)
// pSet: set of particles (ParticleSet)
function resolveRigidShapeCollision(obj, pSet) {
let i, j;
let collision = false;
let rigidShape = obj.getRigidBody();
for (j = 0; j < pSet.size(); j++) {
if (rigidShape.getType() == "RigidRectangle")
collision = resolveRectPos(rigidShape, pSet.getObjectAt(j));
else if (rigidShape.getType() == "RigidCircle")
collision = resolveCirclePos(rigidShape,pSet.getObjectAt(j));
}
return collision;
}
// objSet: set of GameObjects (with potential mRigidBody)
// pSet: set of particles (ParticleSet)
function resolveRigidShapeSetCollision(objSet, pSet) {
let i, j;
let collision = false;
if ((objSet.size === 0) || (pSet.size === 0))
return false;
for (i=0; i<objSet.size(); i++) {
let rigidShape = objSet.getObjectAt(i).getRigidBody();
for (j = 0; j<pSet.size(); j++) {
if (rigidShape.getType() == "RigidRectangle")
collision = resolveRectPos(rigidShape,
pSet.getObjectAt(j)) || collision;
else if (rigidShape.getType() == "RigidCircle")
collision = resolveCirclePos(rigidShape,
pSet.getObjectAt(j)) || collision;
}
}
return collision;
}
export {init,
getSystemAcceleration, setSystemAcceleration,
resolveRigidShapeCollision, resolveRigidShapeSetCollision}
初始化粒子系统
particle_system.js
中定义的临时变量必须在游戏循环开始前初始化。编辑loop.js
,从particle_system.js
导入,在start()
函数中完成异步加载后调用init()
函数。
... identical to previous code ...
import * as debugDraw from "./debug_draw.js";
import * as particleSystem from "../components/particle_system.js";
... identical to previous code ...
async function start(scene) {
... identical to previous code ...
// Wait for any async requests before game-load
await map.waitOnPromises();
// system init that can only occur after all resources are loaded
particleSystem.init();
... identical to previous code ...
}
测试粒子系统
MyGame
类所需的修改非常简单。必须定义一个新的变量来支持冲突解决的切换,在my_game_main.js
中定义的update()
函数修改如下:
update() {
... identical to previous code ...
if (engine.input.isKeyClicked(engine.input.keys.One))
this.mPSCollision = !this.mPSCollision;
if (this.mPSCollision) {
engine.particleSystem.resolveRigidShapeSetCollision(
this.mAllObjs, this.mParticles);
engine.particleSystem.resolveRigidShapeSetCollision(
this.mPlatforms, this.mParticles);
}
... identical to previous code ...
}
观察
与之前的项目一样,您可以运行项目并使用 Q 和 E 键创建粒子。但是,请注意,生成的粒子不会与任何对象重叠。您甚至可以尝试将鼠标指针移动到其中一个RigidShape
对象的边界内,然后键入 Q 键。请注意,在所有情况下,粒子都是在形状外部生成的。
您可以尝试键入 1 键来切换与刚性形状的碰撞。请注意,启用碰撞后,粒子有点类似于火灾或爆炸中的琥珀色粒子,它们从场景中的RigidShape
对象的表面反弹回来。当“碰撞”关闭时,正如您在之前的项目中所观察到的,粒子看起来像是在其他对象前面燃烧或爆炸。这样,碰撞只是控制粒子系统与游戏引擎其余部分集成的另一个参数。
你可能会觉得继续按 Q 键生成粒子很麻烦。在下一个项目中,你将学习在一段固定的时间内粒子的产生。
粒子发射器
使用当前的粒子系统实现,您可以在特定的点和时间创建粒子。这些粒子可以根据它们的性质移动和改变。然而,只有当有一个明确的状态变化时,如按键点击,才能创建粒子。当需要在状态改变后持续生成粒子时,这就变得很受限制,例如在创建一个新的RigidShape
对象后持续一段时间的爆炸或烟火。粒子发射器通过定义在一段时间内生成粒子的功能来解决这个问题。
粒子发射器项目
这个项目演示了如何为你的粒子系统实现一个粒子发射器来支持粒子发射。你可以在图 10-3 中看到这个项目运行的例子。这个项目的源代码位于chapter10/10.3.particle_emitters
文件夹中。
图 10-3
运行粒子发射器项目
该项目的控件与上一个项目相同,并支持所有刚性形状和碰撞控件。项目的粒子系统特定控制如下:
-
Q 键:在当前鼠标位置产生粒子
-
E 键:切换粒子边界的绘制
-
1 键:切换
Particle
/RigidShape
碰撞
该项目的目标如下:
-
为了理解对粒子发射器的需求
-
体验实现粒子发射器
定义粒子发射器类
您已经观察并体验了在处理粒子时避免模式的重要性。在这种情况下,随着ParticleEmitter
对象随着时间的推移生成新的粒子,再次强调注入随机性以避免出现任何图案是很重要的。
-
在
src/engine/particles
文件夹中,创建particle_emitter.js
;用接收位置、数量和如何发射新粒子的构造函数定义ParticleEmitter
类。注意mParticleCreator
变量需要一个回调函数。需要时,将调用该函数来创建粒子。 -
定义一个函数来返回发射器的当前状态。当没有更多的粒子发射时,发射器应该被移除。
let kMinToEmit = 5; // Smallest number of particle emitted per cycle
class ParticleEmitter {
constructor(px, py, num, createrFunc) {
// Emitter position
this.mEmitPosition = [px, py];
// Number of particles left to be emitted
this.mNumRemains = num;
// Function to create particles (user defined)
this.mParticleCreator = createrFunc;
}
... implementation to follow ...
}
export default ParticleEmitter;
- 创建一个函数来实际创建或发射粒子。注意实际发射的粒子数量的随机性以及对
mParticleCreator()
回调函数的调用。采用这种设计,不太可能遇到随着时间推移而产生的粒子数量的模式。此外,发射器仅定义粒子发射的方式、时间和位置的机制,而不定义所创建粒子的特征。mParticleCreator
指向的函数负责定义每个粒子的实际行为。
expired() { return (this.mNumRemains <= 0); }
emitParticles(pSet) {
let numToEmit = 0;
if (this.mNumRemains < this.kMinToEmit) {
// If only a few are left, emits all of them
numToEmit = this.mNumRemains;
} else {
// Otherwise, emits about 20% of what's left
numToEmit = Math.trunc(Math.random() * 0.2 * this.mNumRemains);
}
// Left for future emitting.
this.mNumRemains -= numToEmit;
let i, p;
for (i = 0; i < numToEmit; i++) {
p = this.mParticleCreator(
this.mEmitPosition[0], this.mEmitPosition[1]);
pSet.addToSet(p);
}
}
最后,记得更新引擎访问文件index.js
,以允许游戏开发者访问ParticleEmitter
类。
修改粒子集
定义的ParticleEmitter
类需要集成到ParticleSet
中来管理发射的粒子:
-
编辑
src/engine/particles
文件夹中的particle_set.js
,定义一个新变量用于维护发射器: -
定义一个函数来实例化一个新的发射器。记下
func
参数。这是负责实际创建单个Particle
对象的回调函数。
constructor() {
super();
this.mEmitterSet = [];
}
- 修改更新函数以循环通过发射器集,从而生成新粒子并移除过期发射器:
addEmitterAt(x, y, n, func) {
let e = new ParticleEmitter(x, y, n, func);
this.mEmitterSet.push(e);
}
update() {
super.update();
// Cleanup Particles
let i, obj;
for (i = 0; i < this.size(); i++) {
obj = this.getObjectAt(i);
if (obj.hasExpired()) {
this.removeFromSet(obj);
}
}
// Emit new particles
for (i = 0; i < this.mEmitterSet.length; i++) {
let e = this.mEmitterSet[i];
e.emitParticles(this);
if (e.expired()) { // delete the emitter when done
this.mEmitterSet.splice(i, 1);
}
}
}
测试粒子发射器
这是对ParticleEmitter
对象正确运行的直接测试。修改MyGame
类update()
功能,当按下 G 或 H 键时,在RigidShape
对象的位置创建一个新的ParticleEmitter
。这样,当创建新的RigidShape
对象或给RigidShape
对象分配新的速度时,看起来好像发生了爆炸。
在这两种情况下,本章第一个项目中讨论的_createParticle()
函数都是作为ParticleEmitter
构造函数中的createrFunc
回调函数参数的参数传递的。
观察
运行该项目,并在创建初始RigidShape
对象的位置观察初始的类似烟火的爆炸。键入 G 键,观察新创建的RigidShape
对象附*伴随的爆炸。或者,您可以键入 H 键将速度应用到所有形状,并观察每个RigidShape
对象旁边类似爆炸的效果。为了粗略了解这个粒子系统在游戏中的样子,你可以试着启用纹理(用 T 键),禁用RigidShape
绘制(用 R 键),并键入 H 键来应用速度。注意看起来好像Renderable
物体正在被爆炸炸开。
注意每次爆炸是如何持续一段时间,然后逐渐消失的。将这种效果与短按 Q 键产生的效果进行比较,可以观察到,如果没有专用的粒子发射器,爆炸似乎在开始前就已经失败了。
与粒子类似,发射器也可以具有完全不同的特性来模拟不同的物理效果。例如,您实现的发射器由要创建的粒子数驱动。可以很容易地修改此行为,以使用时间作为驱动因素,例如,在给定的时间段内发射*似数量的粒子。发射器的其他潜在应用包括但不限于
-
允许发射器的位置随时间变化,例如,将发射器连接到火箭的末端
-
允许发射器影响创建的粒子的属性,例如,改变所有创建的粒子的加速度或速度来模拟风的效果
基于你已经实现的简单而灵活的粒子系统,你现在可以用一种简单的方式试验所有这些想法。
摘要
这一章有三个简单的要点。首先,你已经学习了粒子,具有适当纹理和没有维度的位置,在描述有趣的物理效果时是有用的。第二,与其他物体碰撞和互动的能力有助于在游戏场景中整合和放置粒子。最后,为了实现常见的物理效果,粒子的发射应该持续一段时间。
您已经开发了一个简单而灵活的粒子系统,以支持单个粒子及其发射器的一致管理。您的系统很简单,因为它由一个组件组成,定义在particle_system.js
中,只有三个简单的支持类定义在src/engine/particles
文件夹中。该系统是灵活的,因为实际创建粒子的回调机制,游戏开发者可以自由地定义和生成具有任意行为的粒子。
你建立的粒子系统用来演示基本原理。为了增加粒子行为的复杂性,你可以从简单的Particle
类派生出子类,定义额外的参数,并相应地修改update()
函数。为了支持额外的物理效果,你可以考虑从ParticleEmitter
类修改或子类化,并根据你想要的公式发射粒子。
游戏设计注意事项
正如在第九章中所讨论的,在游戏中的存在感不仅仅是通过在游戏环境中重建我们的物理世界体验来实现的;虽然引入真实世界的物理通常是将玩家带入虚拟世界的有效方式,但还有许多其他设计选择可以非常有效地将玩家吸引到游戏中,无论是与对象物理合作还是独立进行。例如,想象一个 2D 漫画书视觉风格的游戏,显示“嘣!”每当有东西爆炸时,基于文本的图像;物体不显示“轰!”当它们在物理世界爆炸时,当然,但程式化和熟悉的使用“轰!”在漫画视觉美学的背景下,如图 10-4 本身就可以非常有效地将玩家与游戏世界中发生的事情联系起来。
图 10-4
像本图中所示的视觉技术经常在漫画小说中使用,以表现各种快速移动或高冲击力的动作,如爆炸、拳击、撞车等;类似的视觉技术也在电影和视频游戏中得到了有效的应用
粒子效果也可以用于模拟我们期望它们在现实世界中的行为的现实方式,或者用于与现实世界物理无关的更具创造性的方式。试着用你从本章的例子中学到的东西,在你当前的游戏原型中试验粒子,就像我们在第九章中离开时一样:你能想出当前关卡中粒子的一些用途来支持和加强现有游戏元素的存在吗(例如,如果玩家角色接触力场,火花就会飞溅)?引入可能与游戏不直接相关但能增强和增加游戏设置趣味性的粒子效果怎么样?
十一、支持摄像机背景
完成本章后,您将能够
-
在任何给定的相机 WC 边界内,用任何图像实现背景*铺
-
理解视差和用视差滚动模拟运动视差
-
理解 2D 游戏中分层物体的需要,并支持分层绘图
介绍
至此,您的游戏引擎能够照亮 2D 图像以生成高光和阴影,并模拟基本的物理行为。作为本书引擎开发的总结,这一章主要关注对使用背景*铺和视差创建游戏世界环境的一般支持,以及减轻游戏程序员管理绘制顺序的负担。
包括背景图像或物体来装饰游戏世界,以进一步吸引玩家。这通常要求图像规模庞大,视觉复杂微妙。例如,在侧滚游戏中,背景必须始终存在,简单的运动视差可以创建深度感,并进一步捕捉玩家的兴趣。
在计算机图形和视频游戏的上下文中,*铺指的是沿着 x 和 y 方向复制图像或图案。在视频游戏中,用于*铺的图像通常被战略性地构建,以确保内容在复制边界上的连续性。图 11-1 显示了一个策略性绘制的背景图像示例,该图像在 x 方向*铺三次,在 y 方向*铺两次。注意跨越复制边界的完美延续。适当的拼贴通过只创建一个单一的图像来传达无限游戏世界中的复杂性。
图 11-1
策略性绘制的背景图像的*铺
视差是当从不同的位置观看时物体的明显位移。图 11-2 显示了一个阴影圆的视差示例。当从中间的眼睛位置观察时,中心阴影圆看起来覆盖了中心矩形块。然而,当从底部眼睛位置观察时,这个相同的阴影圆似乎覆盖了顶部的矩形块。运动视差是观察到当一个人在运动时,附*的物体似乎比远处的物体移动得更快。这是传达深度知觉的基本视觉线索。在 2D 游戏中,运动视差的模拟是一种引入深度复杂性以进一步吸引玩家的直接方法。
图 11-2
视差:从不同的角度观察时,物体出现在不同的位置
这一章介绍了一个用于*铺摄像机 WC 边界的通用算法,并描述了一个隐藏视差滚动细节的抽象。随着背景视觉复杂性的增加,本章讨论了图层管理器的重要性,并创建了一个图层管理器,以减轻游戏程序员对绘制顺序的关注。
背景*铺
在 2D 游戏中*铺背景时,重要的是要认识到只需要绘制覆盖摄像机 WC 边界的*铺。如图 11-3 所示。在本例中,要*铺的背景对象在 WC 原点定义,具有自己的宽度和高度。然而,在这种情况下,相机 WC 边界不与定义的背景对象相交。图 11-3 显示背景对象需要*铺六次以覆盖摄像机 WC 边界。请注意,由于它在摄像机中不可见,因此不需要在原点绘制玩家定义的背景对象。
图 11-3
为摄像机 WC 边界生成*铺背景
有许多方法可以计算给定背景对象和相机 WC 边界所需的*铺。一种简单的方法是确定覆盖 WC 边界左下角的图块位置,并在正 x 和 y 方向上图块。
*铺对象项目
这个项目演示了如何实现简单的背景*铺。你可以在图 11-4 中看到这个项目运行的例子。这个项目的源代码在chapter11/11.1.tiled_objects
文件夹中定义。
图 11-4
运行*铺对象项目
该项目的控制如下:
- WASD 键:移动
Dye
角色(英雄)来*移厕所窗口边界
该项目的目标如下:
-
体验使用多层背景
-
为摄像机 WC 窗口边界实现背景对象的*铺
您可以在assets
文件夹中找到以下外部资源。fonts
文件夹包含默认的系统字体和六幅纹理图像:minion_sprite.png
、minion_sprite_normal.png
、bg.png
、bg_normal.png
、bg_layer.png
和bg_layer_normal.png
。Hero
和Minion
对象由minion_sprite.png
图像中的 sprite 元素表示,bg.png
和bg_layer.png
是两层背景图像。对应的_normal
文件是法线贴图。
定义 TiledGameObject
回想一下,GameObject
抽象了游戏中一个对象的基本行为,它的外观由它引用的Renderable
对象决定。一个TiledGameObject
是一个GameObject
,它能够*铺被引用的Renderable
对象以覆盖给定Camera
对象的 WC 边界。
-
在
src/engine/game_objects
文件夹中创建一个新文件,并将其命名为tiled_game_object.js
。添加以下代码来构造对象。mShouldTile
变量提供了停止*铺过程的选项。 -
为
mShouldTile
定义 getter 和 setter 函数:
class TiledGameObject extends GameObject {
constructor(renderableObj) {
super(renderableObj);
this.mShouldTile = true; // can switch this off if desired
}
… implementation to follow …
export default TiledGameObject;
- 定义函数来*铺并绘制
Renderable
对象,以覆盖aCamera
对象的 WC 边界:
setIsTiled(t) { this.mShouldTile = t; }
shouldTile() { return this.mShouldTile; }
_drawTile(aCamera) {
// Step A: Compute the positions and dimensions of tiling object.
let xf = this.getXform();
let w = xf.getWidth();
let h = xf.getHeight();
let pos = xf.getPosition();
let left = pos[0] - (w / 2);
let right = left + w;
let top = pos[1] + (h / 2);
let bottom = top - h;
// Step B: Get WC positions and dimensions of the drawing camera.
let wcPos = aCamera.getWCCenter();
let wcLeft = wcPos[0] - (aCamera.getWCWidth() / 2);
let wcRight = wcLeft + aCamera.getWCWidth();
let wcBottom = wcPos[1] - (aCamera.getWCHeight() / 2);
let wcTop = wcBottom + aCamera.getWCHeight();
// Step C: Determine offset to camera window's lower left corner.
let dx = 0, dy = 0; // offset to the lower left corner
// left/right boundary?
if (right < wcLeft) { // left of WC left
dx = Math.ceil((wcLeft - right) / w) * w;
} else {
if (left > wcLeft) { // not touching the left side
dx = -Math.ceil((left - wcLeft) / w) * w;
}
}
// top/bottom boundary
if (top < wcBottom) { // Lower than the WC bottom
dy = Math.ceil((wcBottom - top) / h) * h;
} else {
if (bottom > wcBottom) { // not touching the bottom
dy = -Math.ceil((bottom - wcBottom) / h) * h;
}
}
// Step D: Save the original position of the tiling object.
let sX = pos[0];
let sY = pos[1];
// Step E: Offset tiling object and update related position variables
xf.incXPosBy(dx);
xf.incYPosBy(dy);
right = pos[0] + (w / 2);
top = pos[1] + (h / 2);
// Step F: Determine number of times to tile in x and y directions.
let nx = 1, ny = 1; // times to draw in the x and y directions
nx = Math.ceil((wcRight - right) / w);
ny = Math.ceil((wcTop - top) / h);
// Step G: Loop through each location to draw a tile
let cx = nx;
let xPos = pos[0];
while (ny >= 0) {
cx = nx;
pos[0] = xPos;
while (cx >= 0) {
this.mRenderComponent.draw(aCamera);
xf.incXPosBy(w);
--cx;
}
xf.incYPosBy(h);
--ny;
}
// Step H: Reset the tiling object to its original position.
pos[0] = sX;
pos[1] = sY;
}
_drawTile()
函数计算并重新定位Renderable
对象,以覆盖摄像机 WC 边界的左下角,并在正 x 和 y 方向*铺对象。请注意以下几点:
-
启用*铺时,覆盖
draw()
函数以调用_drawTile()
函数: -
步骤 A 和 B 计算*铺对象和摄像机 WC 边界的位置和尺寸。
-
步骤 C 计算
dx
和dy
偏移量,这将*移Renderable
对象,其边界覆盖了aCamera
WC 边界的左下角。对Math.ceil()
函数的调用确保计算出的dx
和dy
是Renderable
宽度和高度的整数倍。这对于确保*铺过程中没有重叠或间隙非常重要。 -
在偏移和绘制之前,步骤 D 保存
Renderable
对象的原始位置。步骤 E 偏移Renderable
对象以覆盖摄像机 WC 边界的左下角。 -
步骤 F 计算所需的重复次数,步骤 G 在正 x 和 y 方向上*铺
Renderable
对象,直到结果覆盖整个摄像机 WC 边界。对Math.ceil()
函数的调用确保计算出的nx
和ny
(在 x 和 y 方向上*铺的次数)是整数。 -
步骤 H 将*铺对象的位置重置为原始位置。
draw(aCamera) {
if (this.isVisible() && (this.mDrawRenderable)) {
if (this.shouldTile()) {
// find out where we should be drawing
this._drawTile(aCamera);
} else {
this.mRenderComponent.draw(aCamera);
}
}
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
修改我的游戏来测试*铺的对象
MyGame
应该测试对象*铺的正确性。为了测试多层*铺,创建了两个单独的实例TiledGameObject
和Camera
。这两个TiledGameObject
实例位于离摄像机不同的距离(z 深度),并由不同的光源组合照亮。增加的第二个摄像头聚焦在一个Hero
物体上。
只对TiledGameObject
实例的创建感兴趣。这是因为一旦创建了一个TiledGameObject
实例,就可以像处理一个GameObject
实例一样处理它。出于这个原因,只详细检查MyGame
类的init()
函数。
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(50, 37.5), // position of the camera
100, // width of camera
[0, 0, 1280, 720] // viewport (X, Y, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
this.mHeroCam = new engine.Camera(
vec2.fromValues(20, 30.5), // position of the camera
14, // width of camera
[0, 420, 300, 300], // viewport (X, Y, width, height)
2
);
this.mHeroCam.setBackgroundColor([0.5, 0.5, 0.9, 1]);
// Step B: the lights
this._initializeLights(); // defined in MyGame_Lights.js
// Step C: the far Background
let bgR = new engine.IllumRenderable(this.kBg, this.kBgNormal);
bgR.setElementPixelPositions(0, 1024, 0, 1024);
bgR.getXform().setSize(30, 30);
bgR.getXform().setPosition(0, 0);
bgR.getMaterial().setSpecular([0.2, 0.1, 0.1, 1]);
bgR.getMaterial().setShininess(50);
bgR.getXform().setZPos(-5);
bgR.addLight(this.mGlobalLightSet.getLightAt(1));
// only the directional light
this.mBg = new engine.TiledGameObject(bgR);
// Step D: the closer Background
let i;
let bgR1 = new engine.IllumRenderable(
this.kBgLayer, this.kBgLayerNormal);
bgR1.getXform().setSize(30, 30);
bgR1.getXform().setPosition(0, 0);
bgR1.getXform().setZPos(-2);
for (i = 0; i < 4; i++) {
bgR1.addLight(this.mGlobalLightSet.getLightAt(i)); // all lights
}
bgR1.getMaterial().setSpecular([0.2, 0.2, 0.5, 1]);
bgR1.getMaterial().setShininess(10);
this.mBgL1 = new engine.TiledGameObject(bgR1);
... identical to previous code ...
}
在列出的代码中,首先在步骤 A 中创建两个摄像机,然后在_initializeLights()
函数中创建并初始化所有光源。步骤 C 将bgR
定义为一个被一个光源照亮的IllumRenderable
的TiledGameObject
。步骤 D 基于被四个光源照亮的另一个IllumRenderable
定义第二个TiledGameObject
。由于TileGameObject
类的mShouldTile
变量默认为 true,两个*铺对象将*铺它们正在绘制的摄像机。
观察
现在,您可以运行项目并使用 WASD 键移动Hero
对象。正如所料,两层*铺背景清晰可见。您可以通过选择并关闭光源 1(键入 1 键,然后键入 H 键)来关闭对更远背景的照明。移动Hero
对象*移摄像机,以验证两个摄像机中的*铺和背景移动行为是否正确。
一个有趣的观察结果是,当两层背景位于离摄像机不同的距离时,当摄像机*移时,两个背景图像同步滚动。如果不是因为光源照明的差异,看起来好像背景实际上是一个单一的图像。这个例子说明了模拟运动视差的重要性。
用视差滚动模拟运动视差
视差滚动通过以不同的速度定义和滚动对象来模拟运动视差,以传达这些对象位于离相机不同距离的感觉。图 11-5 用俯视图说明了这个想法,显示了物体与摄像机的概念距离。由于这是一个鸟瞰图,摄像机 WC 边界的宽度在底部显示为一条水*线。在两层背景Layer1
和Layer2
的前面,Hero
物体是离摄像机最*的。对于典型的 2D 游戏来说,游戏中的绝大多数物体将位于离摄像机的这个默认距离处。背景对象位于离相机更远的位置,在默认距离之后。距离感可以通过背景物体上的战略图来传达(例如Layer1
的草地和Layer2
的远山),并伴有适当的滚动速度。注意背景物体Layer1
和Layer2
上的位置 P 1 和 P 2 在Hero
物体的正后方。
图 11-5
具有两个不同距离的背景对象的场景的俯视图
图 11-6 显示了固定摄像机向左视差滚动的结果。随着Layer1
以比Layer2
更快的速度滚动,位置P1 比P2 从其原始位置有更大的位移。连续滚动将使Layer1
比Layer2
移动得更快,并恰当地传达出它比Layer2
更*的感觉。在视差滚动中,离相机较*的对象总是比较远的对象滚动速度快。
图 11-6
固定相机的视差滚动俯视图
在摄像机运动的情况下,当实现视差滚动时,必须考虑物体的相对速度。图 11-7 用俯视图展示了移动的摄像机和静止的物体的情况。在本例中,摄像机 WC 边界向右移动了d
个单位。由于运动是在相机中进行的,所以相机视图中的所有静止物体将看起来被相机运动的逆运动所取代。例如,静止的Hero
对象从中心向左移动到新 WC 边界的左边缘。为了正确地模拟运动视差,两个背景Layer1
和Layer2
必须移动不同的相对距离。在这种情况下,必须计算相对距离,使得更远的物体看起来移动得更慢。在摄像机移动结束时,在新的 WC 边界内,离摄像机最*的Hero
对象看起来已经向左移动了d
个单位,Layer1
对象移动了0.75d
,而Layer2
对象移动了0.25d
。这样,对象的位移反映了它们与相机的相对距离。为此,Hero
对象的*移为零,Layer1
和Layer2
对象必须分别向右*移0.25d
和0.75d
。请注意,背景向右*移的量小于相机移动的量,因此,背景实际上是向左移动的。例如,虽然Layer1
对象被0.25d
向右*移,但是当从已经被d
向右移动的摄像机来看时,产生的相对移动使得Layer1
对象已经被0.75d
向左移位。
图 11-7
相机运动时视差滚动的俯视图
重要的是要注意,在所描述的为移动的摄像机实现视差滚动的方法中,静止的背景物体被移动。这种方法有两个局限性。首先,改变对象位置是为了传达视觉提示,并不反映任何特定的游戏状态逻辑。如果游戏逻辑要求精确控制背景物体的运动,这会产生具有挑战性的冲突。幸运的是,背景物体通常是为了装饰环境和吸引玩家而设计的。背景物体通常不参与实际的游戏逻辑。第二个限制是静止的背景物体实际上是运动的,并且当从除了引起运动视差的摄像机之外的摄像机观看时,将会出现这种情况。当在存在运动视差的情况下需要来自多个摄像机的视图时,仔细协调它们以避免玩家混淆是很重要的。
视差物体项目
这个项目演示了视差滚动。你可以在图 11-8 中看到这个项目运行的例子。这个项目的源代码在chapter11/11.2.parallax_objects
文件夹中定义。
图 11-8
运行视差对象项目
该项目的控制措施如下:
-
P 键:在模拟视差滚动时,切换不运动的第二台摄像机的画面,以突出背景物体的运动
-
WASD 键:移动
Dye
角色(英雄)来*移厕所窗口边界
该项目的目标如下:
-
为了理解和欣赏运动视差
-
使用视差滚动模拟运动视差
定义 ParallaxGameObject 来实现视差滚动
视差滚动涉及到物体的连续滚动,TiledGameObject
为永不停止的滚动提供了一个方便的*台。因此,ParallaxGameObject
被定义为TiledGameObject
的子类。
- 在
src/engine/game_objects
文件夹中创建parallax_game_object.js
,并添加以下代码来构造对象:
import TiledGameObject from "./tiled_game_object.js";
class ParallaxGameObject extends TiledGameObject {
constructor(renderableObj, scale, aCamera) {
super(renderableObj);
this.mRefCamera = aCamera;
this.mCameraWCCenterRef =
vec2.clone(this.mRefCamera.getWCCenter());
this.mParallaxScale = 1;
this.setParallaxScale(scale);
}
... implementation to follow ...
}
export default ParallaxGameObject;
ParallaxGameObject
对象维护mRefCamera
,一个对aCamera
和mCameraWCCenterRef
的引用,当前 WC 边界中心。这些值用于根据参考摄像机的运动计算相对运动,以支持视差滚动。scale
参数是一个正值。scale
值 1 表示对象位于默认距离,小于 1 的值表示对象在默认距离的前面。大于 1 的scale
表示在默认距离之后的对象。scale
值越大,物体离相机越远。
-
为
mParallaxScale
定义 getter 和 setter 函数。注意负值的箝位;此变量必须是正数。 -
覆盖
update()
函数实现视差滚动:
getParallaxScale() { return this.mParallaxScale; }
setParallaxScale(s) {
this.mParallaxScale = s;
if (s <= 0) {
this.mParallaxScale = 1;
}
}
update() {
// simple default behavior
this._refPosUpdate(); // check to see if the camera has moved
super.update();
}
_refPosUpdate()
功能是根据参考摄像机的 WC 中心位置计算相对位移的功能。
- 定义
_refPosUpdate()
功能:
_refPosUpdate() {
// now check for reference movement
let deltaT = vec2.fromValues(0, 0);
vec2.sub(deltaT,
this.mCameraWCCenterRef, this.mRefCamera.getWCCenter());
this.setWCTranslationBy(deltaT);
// update WC center ref position
vec2.sub(this.mCameraWCCenterRef, this.mCameraWCCenterRef, deltaT);
}
deltaT
变量记录摄像机的移动,setWCTranslationBy()
移动物体模拟视差滚动。
- 定义函数来*移对象以实现视差滚动。底片
delta
的设计目的是将物体向与相机相同的方向移动。注意变量f
是 1 减去mParallaxScale
的倒数。
setWCTranslationBy(delta) {
let f = (1 – (1/this.mParallaxScale));
this.getXform().incXPosBy(-delta[0] * f);
this.getXform().incYPosBy(-delta[1] * f);
}
当mParallaxScale
小于 1 时,倒数大于 1,f
变为负数。在这种情况下,当相机移动时,对象将向相反的方向移动,从而产生对象在默认距离前面的感觉。
相反,当mParallaxScale
大于 1 时,其倒数将小于 1,并导致正f
的值小于 1。在这种情况下,物体的运动方向与相机的运动方向相同,只是速度较慢。更大的mParallaxScale
将对应于更接* 1 的f
值,并且对象的移动将更接*相机的移动,或者对象将看起来离相机更远。
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
在 MyGame 中测试 ParallaxGameObject
ParallaxGameObject
的测试包括在摄像机运动时测试视差滚动的正确性,默认距离的前面和后面都有物体,同时从一个单独的固定摄像机观察ParallaxGameObject
。MyGame
级别的源代码与上一个项目的很大程度上相似,细节就不列出来了。为了说明如何创建ParallaxGameObject
实例,列出了init()
函数的相关部分。
init() {
// Step A: set up the cameras
this.mCamera = new engine.Camera(
vec2.fromValues(50, 37.5), // position of the camera
100, // width of camera
[0, 0, 1280, 720] // viewport (orgX, orgY, width, height)
);
this.mCamera.setBackgroundColor([0.8, 0.8, 0.8, 1]);
// sets the background to gray
this.mParallaxCam = new engine.Camera(
vec2.fromValues(40, 30), // position of the camera
45, // width of camera
[0, 420, 600, 300], // viewport (orgX, orgY, width, height)
2
);
this.mParallaxCam.setBackgroundColor([0.5, 0.5, 0.9, 1]);
// Step B: the lights
this._initializeLights(); // defined in MyGame_Lights.js
// Step C: the far Background
let bgR = new engine.IllumRenderable(this.kBg, this.kBgNormal);
bgR.setElementPixelPositions(0, 1024, 0, 1024);
bgR.getXform().setSize(30, 30);
bgR.getXform().setPosition(0, 0);
bgR.getMaterial().setSpecular([0.2, 0.1, 0.1, 1]);
bgR.getMaterial().setShininess(50);
bgR.getXform().setZPos(-5);
// only the directional light
bgR.addLight(this.mGlobalLightSet.getLightAt(1));
this.mBg = new engine.ParallaxGameObject(bgR, 5, this.mCamera);
// Step D: the closer Background
let i;
let bgR1 = new engine.IllumRenderable(
this.kBgLayer, this.kBgLayerNormal);
bgR1.getXform().setSize(25, 25);
bgR1.getXform().setPosition(0, -15);
bgR1.getXform().setZPos(0);
// the directional light
bgR1.addLight(this.mGlobalLightSet.getLightAt(1));
// the hero spotlight light
bgR1.addLight(this.mGlobalLightSet.getLightAt(2));
// the hero spotlight light
bgR1.addLight(this.mGlobalLightSet.getLightAt(3));
bgR1.getMaterial().setSpecular([0.2, 0.2, 0.5, 1]);
bgR1.getMaterial().setShininess(10);
this.mBgL1 = new engine.ParallaxGameObject(bgR1, 3, this.mCamera);
// Step E: the front layer
let f = new engine.TextureRenderable(this.kBgLayer);
f.getXform().setSize(50, 50);
f.getXform().setPosition(-3, 2);
this.mFront = new engine.ParallaxGameObject(f, 0.9, this.mCamera);
... identical to previous code ...
}
将mBg
对象创建为scale
为 5 的ParallaxGameObject
、scale
为 3 的mBgL1
和scale
为 0.9 的mFront
。回想一下scale
是ParallaxGameObject
构造函数的第二个参数。此参数表示物体与相机的距离,大于 1 的值表示距离更远,小于 1 的值表示距离更*。在这种情况下,mBg
离摄像机最远,而mBgL1
更*。无论如何,两者仍落后于默认距离。mFront
物体离摄像机最*,在默认距离前或在Hero
物体前。
观察
现在你可以运行这个项目,观察较暗的前景层部分遮挡了Hero
和Minion
对象。你可以移动Hero
物体来*移相机,观察两个背景层以不同的速度滚动。mBg
物体距离更远,因此滚动速度比mBgL1
物体慢。你还会注意到前层视差滚动的速度比所有其他物体都快,因此,*移相机会显示静止Minion
物体的不同部分。
按 P 键启用第二台摄像机的绘制。请注意,当Hero
静止时,该相机中的视图与预期一样,没有移动。现在,如果您移动Hero
对象来*移主摄像机,请注意第二个摄像机视图中的前景和背景对象也在移动,并且表现出运动视差,即使第二个摄像机没有移动!作为游戏设计者,确保这种副作用不会困扰玩家是很重要的。
层管理
虽然你正在开发的引擎是为 2D 游戏开发的,但是你已经处理过一些深度排序和绘制顺序很重要的情况。例如,阴影接收器必须始终定义在阴影投射器的后面,正如前面的示例中所讨论的,前景和背景视差对象必须仔细定义,并按照其深度排序的顺序绘制。游戏引擎提供一个工具管理器来帮助游戏程序员管理和使用深度分层是很方便的。一个典型的 2D 游戏可以有以下层,按照离摄像机的距离从最*到最远的顺序:
-
*视显示器(HUD)层:通常,最靠*显示重要用户界面信息的摄像机
-
前景或前层:游戏对象前面的层,用于装饰或部分遮挡游戏对象
-
演员层:图 11-5 中默认的距离层,所有游戏对象都驻留在此
-
阴影接收层:演员层后面的层,用于接收潜在的阴影
-
背景层:装饰背景
每个图层都将引用为该图层定义的所有对象,这些对象将按照它们插入图层的顺序进行绘制,最后插入的对象在最后绘制,覆盖对象在它之前。本节介绍了支持所述五层的Layer
引擎组件,以将游戏程序员从管理更新和绘制对象的细节中解放出来。请注意,游戏引擎应该支持的层数由引擎设计构建的游戏种类决定。所呈现的五层对于简单的游戏来说是合乎逻辑和方便的。你可以选择在你自己的游戏引擎中增加层数。
图层管理器项目
这个项目演示了如何开发一个工具组件来帮助游戏程序员管理游戏中的层。你可以在图 11-9 中看到这个项目运行的例子。这个项目的源代码在chapter11/11.3.layer_manager
文件夹中定义。
图 11-9
运行图层管理器项目
该项目的控件与之前的项目相同:
-
P 键:在模拟视差滚动时,切换不运动的第二台摄像机的画面,以突出背景物体的运动
-
WASD 键:移动染色角色(英雄)来*移厕所窗口边界
该项目的目标如下:
-
为了理解分层在 2D 游戏中的重要性
-
开发图层管理器引擎组件
引擎中的层管理
例如,遵循定义引擎组件的模式,类似于物理和粒子系统的模式:
-
在
src/engine/components
文件夹中创建一个新文件,并将其命名为layer.js
。这个文件将实现Layer
引擎组件。 -
为层定义枚举器:
-
定义适当的常数和实例变量来跟踪层。
mAllLayers
变量是代表五层中每一层的GameObjectSet
实例的数组。
const eBackground = 0;
const eShadowReceiver = 1;
const eActors = 2;
const eFront = 3;
const eHUD = 4;
- 定义一个
init()
函数来创建GameObjectSet
实例的数组:
let kNumLayers = 5;
let mAllLayers = [];
- 定义一个
cleanUp()
函数来重置mAllLayer
数组:
function init() {
mAllLayers[eBackground] = new GameObjectSet();
mAllLayers[eShadowReceiver] = new GameObjectSet();
mAllLayers[eActors] = new GameObjectSet();
mAllLayers[eFront] = new GameObjectSet();
mAllLayers[eHUD] = new GameObjectSet();
}
- 定义添加、移除和查询图层的函数。注意
addAsShadowCaster()
函数假设阴影接收器对象已经插入到eShadowReceiver
层,并将投射对象添加到该层的所有接收器中。
function cleanUp() {
init();
}
- 定义绘制特定层或所有层的函数,从最远到最*的摄像机:
function addToLayer(layerEnum, obj) {
mAllLayers[layerEnum].addToSet(obj); }
function removeFromLayer(layerEnum, obj) {
mAllLayers[layerEnum].removeFromSet(obj); }
function layerSize(layerEnum) { return mAllLayers[layerEnum].size(); }
function addAsShadowCaster(obj) {
let i;
for (i = 0; i < mAllLayers[eShadowReceiver].size(); i++) {
mAllLayers[eShadowReceiver].getObjectAt(i).addShadowCaster(obj);
}
}
- 定义一个函数来移动特定对象,使其最后绘制(在顶部):
function drawLayer(layerEnum, aCamera) {
mAllLayers[layerEnum].draw(aCamera); }
function drawAllLayers(aCamera) {
let i;
for (i = 0; i < kNumLayers; i++) {
mAllLayers[i].draw(aCamera);
}
}
- 定义更新特定图层或所有图层的函数:
function moveToLayerFront(layerEnum, obj) {
mAllLayers[layerEnum].moveToLast(obj);
}
- 记住导出所有已定义的功能:
function updateLayer(layerEnum) { mAllLayers[layerEnum].update(); }
function updateAllLayers() {
let i;
for (i = 0; i < kNumLayers; i++) {
mAllLayers[i].update();
}
}
export {
// array indices
eBackground, eShadowReceiver, eActors, eFront, eHUD,
// init and cleanup
init, cleanUp,
// draw/update
drawLayer, drawAllLayers,
updateLayer, updateAllLayers,
// layer-specific support
addToLayer, addAsShadowCaster,
removeFromLayer, moveToLayerFront,
layerSize
}
最后,记得更新引擎访问文件index.js
,以便将新定义的功能转发给客户端。
修改引擎组件和对象
你必须稍微修改游戏引擎的其余部分来整合新的Layer
组件。
增强游戏对象集功能
添加以下函数以支持将对象移动到集合数组的末尾:
moveToLast(obj) {
this.removeFromSet(obj);
this.addToSet(obj);
}
初始化 index.js 中的图层
除了导入/导出Layer
组件外,修改index.js
中的引擎init()
和cleanUp()
函数来初始化和清理组件:
... identical to previous code ...
function init(htmlCanvasID) {
glSys.init(htmlCanvasID);
vertexBuffer.init();
input.init(htmlCanvasID);
audio.init();
shaderResources.init();
defaultResources.init();
layer.init();
}
function cleanUp() {
layer.cleanUp();
loop.cleanUp();
shaderResources.cleanUp();
defaultResources.cleanUp();
audio.cleanUp();
input.cleanUp();
vertexBuffer.cleanUp();
glSys.cleanUp();
}
定义图层成员的更新函数
为可能作为成员出现在Layer
: Renderable
和ShadowReceiver
中的对象定义更新函数。
修改 MyGame 以使用层组件
MyGame
级别实现了与前一个项目相同的功能。唯一的区别是层管理委托给了Layer
组件。以下描述仅关注与层管理相关的函数调用。
-
修改
unload()
函数来清理Layer
: -
修改
init()
函数,将游戏对象添加到Layer
组件中的相应层:
unload() {
engine.layer.cleanUp();
engine.texture.unload(this.kMinionSprite);
engine.texture.unload(this.kBg);
engine.texture.unload(this.kBgNormal);
engine.texture.unload(this.kBgLayer);
engine.texture.unload(this.kBgLayerNormal);
engine.texture.unload(this.kMinionSpriteNormal);
}
- 修改
draw()
功能,以依赖Layer
组件进行实际绘图:
init() {
... identical to previous code ...
// add to layer managers ...
engine.layer.addToLayer(engine.layer.eBackground, this.mBg);
engine.layer.addToLayer(engine.layer.eShadowReceiver,
this.mBgShadow1);
engine.layer.addToLayer(engine.layer.eActors, this.mIllumMinion);
engine.layer.addToLayer(engine.layer.eActors, this.mLgtMinion);
engine.layer.addToLayer(engine.layer.eActors, this.mIllumHero);
engine.layer.addToLayer(engine.layer.eActors, this.mLgtHero);
engine.layer.addToLayer(engine.layer.eFront, this.mBlock1);
engine.layer.addToLayer(engine.layer.eFront, this.mBlock2);
engine.layer.addToLayer(engine.layer.eFront, this.mFront);
engine.layer.addToLayer(engine.layer.eHUD, this.mMsg);
engine.layer.addToLayer(engine.layer.eHUD, this.mMatMsg);
}
- 修改
update()
函数,依靠Layer
组件对所有游戏对象进行实际更新:
draw() {
engine.clearCanvas([0.9, 0.9, 0.9, 1.0]); // clear to light gray
this.mCamera.setViewAndCameraMatrix();
engine.layer.drawAllLayers(this.mCamera);
if (this.mShowParallaxCam) {
this.mParallaxCam.setViewAndCameraMatrix();
engine.layer.drawAllLayers(this.mParallaxCam);
}
}
update() {
this.mCamera.update(); // to ensure proper interpolated movement
this.mParallaxCam.update();
engine.layer.updateAllLayers();
... identical to previous code ...
}
观察
您现在可以运行该项目,并观察与上一个项目相同的输出和交互。对这个项目的重要观察是在实现中。通过在init()
期间将游戏对象插入到Layer
组件的适当层,游戏关卡的draw()
和update()
功能可以更加简洁。更简单、更干净的update()
功能尤为重要。这个函数现在可以专注于实现游戏逻辑和控制游戏对象之间的交互,而不是被*凡的游戏对象update()
函数调用所困扰。
摘要
本章解释了*铺的必要性,并介绍了TileGameObject
来实现一个简单的算法,*铺并覆盖给定的摄像机 WC 边界。介绍了视差的基本原理和用视差滚动模拟运动视差的方法。检查了静止和移动摄像机的运动视差,并推导和实现了解决方案。您已经了解到,计算相对于摄像机运动的移动来替换背景对象会产生视觉上令人满意的运动视差,但当从不同的摄像机观看时,可能会导致玩家混淆。随着早期引入的阴影计算和现在的视差滚动,游戏程序员必须投入代码和注意力来协调不同类型对象的绘制顺序。为了促进游戏引擎的可编程性,Layer
引擎组件被呈现为一个实用工具,以将游戏程序员从管理层的绘制中解放出来。
这本书提出的游戏引擎现在已经完成。它可以用纹理贴图、精灵动画来绘制对象,甚至支持各种光源的照明。该引擎为简单行为定义适当的抽象,实现*似和精确计算碰撞的机制,并模拟物理行为。来自多个摄像机的视图可以方便地显示在相同的游戏屏幕上,具有*滑插值的操作功能。支持键盘/鼠标输入,现在背景对象可以无边界滚动,模拟运动视差。
重要的下一步,正确地测试你的引擎,是通过一个简单的游戏设计过程,并实现一个基于你新完成的游戏引擎的游戏。
游戏设计注意事项
在前面的章节中,您已经探索了如何从头开始开发一个简单的游戏机制,它可以引导许多方向并应用于各种类型的游戏。游戏设计工作室的创意团队经常争论游戏设计的哪些元素在创意过程中起主导作用:作家通常认为故事是第一位的,而许多设计师认为故事和其他一切都必须从属于游戏性。当然,没有正确或错误的答案;创作过程是一个混乱的系统,每个团队和工作室都是独一无二的。一些创意总监希望讲述一个特定的故事,并将寻找最适合支持特定叙事的机制和类型,而其他人则是游戏纯粹主义者,完全致力于“游戏性优先,其次,最后”的文化这个决定通常归结为理解你的听众;例如,如果你正在创建竞争性的多人第一人称射击游戏体验,消费者将对游戏的许多核心元素有特定的期望,确保游戏性驱动设计通常是一个明智的举动。然而,如果你正在创建一个冒险游戏,旨在讲述一个故事,并为玩家提供新的体验和意想不到的转折,故事和设定可能会引领潮流。
许多游戏设计者(包括经验丰富的老手和新手)开始新的项目时,设计的体验是对现有的熟知机制的相对较小的改变;虽然这种方法有很好的理由(如 AAA 工作室为特别苛刻的观众开发内容,或者希望与许多游戏中已被证明成功的机制合作),但它往往会极大地限制对新领域的探索,这也是许多游戏玩家抱怨同一类型游戏之间创意停滞和缺乏游戏多样性的一个原因。许多专业游戏设计师从小就喜欢某些类型的游戏,并梦想着基于我们所知道和喜爱的机制创造新的体验,几十年来,这种文化已经将行业的大部分注意力集中在相对较少的类似机制和惯例上。也就是说,*年来,一个快速增长的独立小型工作室社区已经大胆地开始抛弃长期存在的类型传统,移动应用商店和 Valve 的 Steam 等易于访问的分发*台为各种新的游戏机制和体验的蓬勃发展提供了机会。
如果你继续探索游戏设计,你会意识到完全独特的核心机制相对较少,但随着你将这些基本的交互构建到更复杂的因果链中,并通过与游戏设计的其他元素的优雅集成来添加独特的味道和纹理,创新的机会是无穷的。一些最具突破性和最成功的游戏是通过练习创建的,这些练习非常类似于您在这些“游戏设计考虑事项”部分中所做的机械探索;例如,Valve 的门户网站是基于你一直在探索的同类“逃离房间”沙盒,并且是围绕一个类似的简单基础机制设计的。是什么让《传送门》取得如此突破性的成功?虽然创建一个热门游戏需要许多东西,但 Portal 受益于一个设计团队,该团队开始从最基本的机制开始构建体验,并随着他们对其独特的结构和特征越来越熟悉而智能地增加复杂性,而不是从 10,000 英尺的高度开始,采用一种已编纂的类型和一套预定的设计规则。
当然,没有人在谈论传送门时不提到流氓人工智能角色 GLaDOS 和她的 Aperture Laboratories 游乐场:设置、叙事和视听设计对于传送门体验来说与传送门启动游戏机制一样重要,鉴于它们是如何巧妙地交织在一起,很难将游戏性与叙事分开。本章中的项目提供了一个很好的机会,让我们从“游戏设计考虑事项”部分开始,在一个独特的环境和背景中类似地定位游戏机制:你可能已经注意到本书中的许多项目都朝着科幻视觉主题发展,有穿着宇航服的英雄角色,各种各样的飞行机器人,现在在第十一章中引入了视差环境。虽然你没有像《传送门》一样构建一个环境和交互复杂程度相同的游戏,但这并不意味着你没有同样的机会来开发一个高度引人入胜的游戏环境、背景和角色。
关于*铺对象项目,你应该注意到的第一件事是与早期项目相比,对环境体验和规模的巨大影响。在这个项目中,增强存在感的因素是三个独立移动的层(英雄人物、移动的墙和静止的墙)以及两个背景层的无缝拼接。将*铺对象项目与第八章中的阴影着色器项目进行比较,并注意当环境被分成多个层时存在的差异,这些层似乎以类似于(如果不是物理上准确的)您在物理世界中体验运动的方式移动。在视差物体项目中加入多个视差运动的背景层,存在感进一步加强;当你在物质世界中移动时,环境似乎以不同的速度移动,较*的物体似乎快速通过,而靠*地*线的物体似乎移动缓慢。视差环境对象模拟了这种效果,为游戏环境增加了相当大的深度和趣味性。层管理器项目将所有的事情整合在一起,并开始展示游戏设置的潜力,立即吸引玩家的想象力。只需几项技术,你就能创造出一个巨大环境的印象,这个环境可能是古代外星机器的内部,大型宇宙飞船的外部,或者你想创造的任何东西。尝试用这种技术使用不同种类的图像资产:外部景观、水下位置、抽象形状等等都是有趣的探索。你经常会通过尝试一些基本元素来寻找游戏设置的灵感,就像你在第十一章中所做的那样。
将环境设计(音频和视觉)与交互设计(偶尔包括类似控制器振动的触觉反馈)相结合,是一种可以用来创建和增强临场感的方法,环境和交互与游戏机制的关系贡献了玩家在游戏中的大部分体验。环境设计和叙事背景创造了游戏设定,正如前面提到的,最成功和最令人难忘的游戏实现了游戏设定和玩家体验之间的完美和谐。在这一点上,来自第九章【游戏设计注意事项】部分的游戏机制已经被有意地去除了任何游戏设定背景,你只是简单地考虑了交互设计,让你自由地探索任何你感兴趣的设定。在第十二章中,你将通过“游戏设计注意事项”部分的解锁机制进一步发展主要章节项目中使用的科幻设定和图像资产,以创建一个相当先进的 2D *台游戏级原型。
十二、构建一个示例游戏:从设计到完成
第 1 到 11 章节主要章节所包含的项目,从简单的形状开始,慢慢引入人物和环境,来说明每一章的概念;这些项目专注于个人行为和技术(如碰撞检测、物体物理、照明等),但缺乏提供完整游戏体验所需的结构化挑战。“设计考虑”部分的项目展示了如何引入将基本行为转化为良好的游戏机制所需的逻辑规则和挑战。本章现在改变重点,强调从早期概念到功能原型的设计过程,通过使用前几章中的一些角色和环境以及第十一章“设计考虑事项”部分中解锁*台游戏的基本思想,将早期项目中所做的工作汇集并扩展。和前面的章节一样,这里使用的设计框架从一个简单灵活的初始模板开始,并有意识地增加复杂性,以允许游戏以可控的方式发展。
到目前为止,设计练习一直避免考虑游戏设计的九个要素中的大部分,这些要素在“你如何制作一个伟大的视频游戏?”第一章的部分,而是专注于制作基本的游戏机制,以便清晰地定义和提炼游戏本身的核心特征。本书中使用的设计方法是一个全新的框架,强调在考虑游戏的类型或设定之前,首先使用一个孤立的游戏机制;当你开始加入一个设定,并在核心机制的基础上构建包含额外设计元素的关卡时,随着游戏世界的发展,游戏性将会朝着独特的方向发展和进化。一个游戏的机制和你设计的相关游戏循环有无穷无尽的潜在变化。你会惊讶地发现,基于你所做的创造性选择,同样的游戏基本元素会有多么不同的发展和演变。
第一部分:提炼概念
至此,您应该已经有了使用 2D 跳跃和解谜机制的概念,该机制围绕解锁障碍和获得奖励展开。从第十一章中调出图 12-1 作为最终的屏幕布局和设计。
图 12-1
第十一章 2D 实现从
这个设计已经有了一个多阶段的解决方案,要求玩家既要展示基于时间的灵活性,又要展示解谜逻辑。在目前的设计中,玩家控制英雄角色(可能通过使用 A 和 D 键左右移动,使用空格键跳跃)。玩家可以在同一层的水**台之间跳跃,但如果不使用升降的中间“电梯”*台,就无法到达上面的*台。如果玩家触摸一个水*的“能量场”,它就会电击玩家,导致游戏重置。完成该级别的明确步骤如下:
-
玩家必须在移动的电梯*台(图 12-1 中的#1)上跳下英雄角色(图 12-1 中间带字母 p 的圆圈)并跳下到右侧立柱的中间*台,才能触碰到能量场。
-
玩家通过碰撞英雄角色来激活能量场的关闭开关(#2,由图 12-1 中的灯泡图标表示)。
-
当能量场关闭时,玩家乘坐电梯*台到达顶部(#3),并使英雄跳到右栏的顶部*台。
-
玩家将英雄与代表锁图标上三分之一的小圆圈(#4)碰撞,激活锁图标的相应部分,使其发光。
-
玩家将英雄跳回电梯*台(#5),然后将英雄跳到右栏的底部*台。
-
玩家将英雄与锁图标中间部分对应的形状(#6)碰撞,激活锁图标对应的部分,使其发光。三分之二的锁图标现在发光,表示进度。
-
玩家在电梯*台上再跳一次英雄(#7),然后将英雄跳至左栏顶部*台。
-
玩家将英雄与锁图标(#8)底部对应的形状碰撞,激活图标的最后一部分,解锁关卡。
鉴于你已经创建的模拟屏幕,写出这个序列(或游戏流程图)可能看起来没有必要。然而,对于设计师来说,重要的是要理解玩家必须按照准确的顺序和细节做的每一件事,以确保你能够调整、*衡和发展游戏,而不会陷入复杂性或忘记玩家如何通过关卡。从前面的游戏流程图中可以清楚地看到,例如,电梯*台是这一关的核心,是完成每个动作所必需的;这是示意图和游戏流程描述中可用的重要信息,因为它提供了一个机会来智能地完善游戏逻辑,使您可以可视化每个变化对整个关卡流程的影响。
你可以继续构建机制,使关卡更有趣和更具挑战性(例如,你可以在能量场的关闭开关上包括一个计时器,要求玩家在有限的时间内碰撞所有的锁部件)。然而,在概念开发的这个阶段,从游戏性后退一步,开始考虑游戏设置和类型,使用这些元素来帮助告知游戏机制如何从这里演变,通常是有帮助的。
回想一下第十一章,这些项目以一系列支持科幻场景的概念探索结束。图 12-2 展示了一个未来派的工业环境设计,一个穿着太空服的英雄角色,以及看起来像是会飞的机器人。
图 12-2
第十一章中的概念
请注意,你一直在创造的游戏机制没有任何具体的东西会把你引向科幻的方向;游戏机制是抽象的互动结构,通常可以与任何类型的设置或视觉风格相结合。在这种情况下,作者选择了一个发生在宇宙飞船上的背景,所以本章将使用这个主题作为游戏原型的背景。在设计过程中,考虑探索其他场景:第十一章中的游戏机制如何适应丛林场景、当代城市场景、中世纪幻想世界或水下大都市?
第二部分:集成设置
现在是开始分配一些基本的虚构背景的好时机,以独特的方式进化和扩展游戏机制,增强你选择的设置(不要担心,如果现在这还不清楚;随着关卡设计的进行,这个机制会变得更加明显。例如,想象一下,英雄人物是一艘大型宇宙飞船上的一名船员,她必须完成许多目标以防止飞船爆炸。再一次,没有任何关于驱动这个故事的游戏机制的现状;这一阶段的设计任务包括头脑风暴一些虚构的背景,推动玩家通过游戏并捕捉他们的想象力。使用已经创建/提供的一些概念艺术资产,英雄可以很容易地参与一场比赛,寻找丢失的东西,探索一艘被遗弃的外星船只,或者一百万种其他可能性中的任何一种。
背景图片赋予场景以生命
现在,您已经描述了一个基本的叙事和虚构的包装,内容类似于“玩家必须完成一系列*台谜题关卡,以在飞船爆炸前拯救飞船”,只需将之前早期原型的一些形状与一些包含的概念元素进行交换。图 12-3 介绍了一个人形英雄角色,感觉有点像飞船部件的*台,以及一个带锁的门的障碍墙,以取代机械设计中的抽象锁。
图 12-3
引入几个视觉设计元素来支持游戏设定和不断发展的叙事
虽然你只做了一些小的替换,还没有将视觉元素固定在一个环境中,但图 12-3 比图 12-1 的抽象形状传达了更多的虚构背景,并对在场做出了更大的贡献。英雄角色现在建议了一个尺度,当玩家将相对大小与人形进行比较时,该尺度将自然地被上下文化,这将整个游戏环境的相对大小带入玩家的焦点。在第十章中描述的英雄角色的物理实现也成为游戏的一个重要组成部分:模拟的重力、动量等将玩家与英雄角色在游戏世界中的移动联系起来。通过实现图 12-3 中描述的设计,你已经完成了一些令人印象深刻的认知壮举,仅仅通过添加一些视觉元素和一些物体物理就可以支持在场。
定义可玩空间
在设计过程的这一点上,你已经充分描述了游戏的核心机制和设置,开始将单个屏幕扩展为一个完整的概念。在这个阶段定义一个最终的视觉风格并不重要,但是包含一些概念艺术将有助于指导水*如何增长。(图 12-3 提供了一个很好的视觉展示,展示了在给定对象比例的情况下,在单个屏幕上将会发生的游戏数量。)这也是一个很好的阶段,将图 12-3 中的元素“封闭”在一个工作原型中,开始感受运动的感觉(例如,英雄角色奔跑的速度,英雄可以跳跃的高度,等等),环境中物体的比例,相机的变焦水*,等等。在这个阶段不需要包括交互和行为,比如锁组件或能量场,因为你还没有设计关卡将如何进行。现在你正在试验基本的英雄角色移动、物体放置和碰撞。下一组任务包括布局整个级别和调整所有交互。
图 12-3 中设计的当前状态仍然需要一些工作来提供足够的挑战。虽然一个结构良好的关卡的所有要素都已到位,但当前的难度是微不足道的,大多数玩家将可能能够快速完成该关卡。然而,有一个强大的基础来开始扩展跳跃和排序机制;首先,你可以扩展水*游戏空间以包含更多可玩的区域,并为角色提供额外的活动空间,如图 12-4 所示。
图 12-4
关卡设计增加了额外的可玩区域
回想一下第七章中的简单相机操作项目,你可以通过移动角色靠*边界区域的边缘来“推动”游戏屏幕向前,这允许你设计一个远远超出单一静态屏幕尺寸的关卡。当然,你可能会选择将这个级别限制在原始游戏屏幕的大小之内,并增加基于时间的敏捷性和逻辑顺序挑战的复杂性(事实上,这是一个挑战自己在空间限制内工作的良好设计练习),但出于这种设计的目的,水*滚动演示增加了兴趣和挑战。
向可玩空间添加布局
现在是时候开始布置楼层了,以充分利用额外的水*空间。在这一点上没有必要改变基本的游戏玩法;你只需要扩展当前的关卡设计来适应游戏屏幕的新尺寸。图 12-5 包括一些额外的*台,除了确保玩家可以成功到达每个*台外,没有特别的方法。
图 12-5
扩展布局以使用额外的屏幕空间,该图显示了第一阶段的整个长度,玩家可以在任何时候看到整个关卡的大约 50%。当玩家向屏幕边界区域移动英雄角色时,摄像机向前或向后滚动屏幕。注意:对于所示的移动*台,深色箭头表示方向,浅色箭头表示*台移动的范围
现在这个级别有了一些额外的工作空间,有几个因素需要评估和调整。例如,图 12-5 中的英雄角色的比例已经缩小,以增加单个屏幕上可以执行的垂直跳跃次数。请注意,在这一点上,如果需要的话,你也有机会在设计中加入额外的垂直游戏,实现你用来左右移动相机的相同机制来上下移动相机;许多 2D *台游戏允许玩家在游戏世界中横向和纵向移动。为了简单起见,这个关卡原型将限制移动到 x *面(左和右),尽管您可以很容易地扩展关卡设计,以便在未来的迭代和/或后续的关卡中包括垂直游戏。
当你在关卡中放置*台时,你会再次希望在阻止游戏流程的同时最小化设计的复杂性。图 12-5 增加了一个额外的设计元素:一个从左向右移动的*台。使用与图 12-1 所示相同的编号方法,尝试列出图 12-5 中三个锁段启动所需的详细顺序。当你完成绘制顺序后,将其与图 12-6 进行比较。
图 12-6
打开屏障最有效的顺序
你的顺序符合图 12-6 吗,或者你有额外的步骤?有许多潜在的路径可供玩家选择来完成这一关,很可能没有两个玩家会选择相同的路线(机械设计的唯一要求是从上到下按顺序激活锁部分)。
调整挑战并增加乐趣
在设计的这个阶段是拼图制作过程真正开始的时候;图 12-6 展示了只用你一直在使用的几个基本元素来创造高度吸引人的游戏的潜力。作者在多种游戏的头脑风暴会议中使用了之前的模板和类似的变化——向一个众所周知的机制引入一两个新元素,并探索新添加的元素对游戏性的影响——结果通常会开辟令人兴奋的新方向。作为一个例子,你可以介绍出现和消失的*台,开关被激活后旋转的*台,移动的能量场,传送站,等等。构建这种机制的方法当然是无限的,但是当前的模板有足够的定义,添加一个新元素是相当容易试验和测试的,即使是在纸上。
新扩展的关卡设计有两个因素增加了挑战。首先,水*移动*台的增加需要玩家更精确地计算跳跃到“电梯”*台的时间(如果他们在*台上升时跳跃,在它电击他们之前几乎没有时间停用能量场)。第二个因素不太明显,但同样具有挑战性:在任何时候只有一部分关卡是可见的,所以玩家无法轻松地创建整个关卡序列的心理模型,就像他们可以在单个屏幕上看到整个布局一样。对于设计师来说,理解明确的挑战(例如要求玩家在两个移动*台之间进行时间跳跃)和不太明显(通常是无意的)的挑战(例如在任何给定时间只能看到关卡的一部分)都很重要。回想一下你玩过的一个游戏,感觉设计者期望你记住太多的元素;这种挫折感通常是由于无意的挑战超过了玩家在短期记忆中可以合理保持的内容。
作为一名设计师,你需要意识到隐藏的挑战和无意的挫折或困难;这些是为什么尽可能早和经常地观察人们玩你的游戏是至关重要的关键原因。一般来说,任何时候你 100%确定你已经设计出完美的东西,至少有一半玩你的游戏的人会告诉你完全相反的事情。虽然详细讨论用户测试的好处超出了本书的范围,但是你应该计划观察人们从最早的概念验证一直到最终发布都在玩你的游戏。没有什么可以替代你从观看不同的人演奏你设计的作品中获得的洞察力。
前面图中描述的关卡目前假设英雄角色只能在*台上休息;虽然没有设计计划说明如果角色错过一次跳跃并掉到屏幕底部会发生什么,但玩家可能会合理地想象这会导致失败并触发游戏重置。如果你在关卡中增加了一个“地板”,玩家的策略将会发生显著的变化;除了消除重大风险,玩家还可以直接进入电梯*台,如图 12-7 所示。
图 12-7
游戏世界增加了一个“地板”,极大地改变了关卡挑战
进一步调整:引入敌人
你现在正在尝试关卡布局的变化,以使其随着设定而发展,并寻找增加玩家参与度的方法,同时也增加挑战(如果需要的话)。在添加楼层之前,关卡有两个风险:未能降落在*台上并触发损失条件,以及与能量场碰撞并触发损失条件。地板的添加消除了坠落的风险,并潜在地降低了关卡的挑战性,但你可能会决定地板鼓励玩家更自由地探索、实验和更密切地关注环境。你现在也越来越熟悉这种机制和布局的游戏性和流程,所以让我们引入一个新元素:攻击敌人(我们不能让前几章的机器人设计浪费掉)!图 12-8 介绍了两种基本的敌方机器人类型:一种发射炮弹,一种只是巡逻。
图 12-8
关卡中引入了两种新的物体类型:垂直移动并以恒定速度射击的射击机器人(#1)和在特定范围内来回移动的巡逻机器人(#2)
你现在已经到达了这一关设计的转折点,这里的设置开始对机制和游戏循环的发展产生重大影响。机制的核心从第十一章开始就没有改变,这一关基本上仍然是按照正确的顺序激活锁的各个部分来移除障碍,但是移动的*台和攻击的敌人是额外的障碍,并且受到你选择的特定设置的强烈影响。
当然,你当然可以添加攻击敌人的行为,同时仍然使用抽象的形状和纯力学。然而,值得注意的是,一个机制变得越复杂和多阶段,设置就越需要符合实现;这就是为什么从纯粹抽象的机械设计过渡到在一个特殊的环境中设计一个关卡(或者一个关卡的一部分)是很有帮助的,因为机械仍然是相当基本的。设计者通常希望游戏机制能与游戏环境深度融合,因此让两者协同发展是有益的。找到最佳点可能很有挑战性:有时机械师主导设计,但随着场景的演变,它通常会进入驾驶员的位置。过早引入设定,你会失去对精炼纯粹游戏性的关注;太晚引入设定,游戏世界可能会感觉像是一种事后的想法或者是附加的东西。
总则
回到当前的设计,如图 12-8 所示,你现在拥有了在新兴环境中创造一个真正吸引人的序列所需的所有元素。你也可以很容易地调整单个单位的移动和位置,使事情变得更具挑战性或更不具挑战性。玩家将需要观察*台和敌人的运动模式来计时他们的跳跃,这样他们就可以在不被电击或撞击的情况下导航,同时发现并解决解锁难题。请注意这一关是如何迅速地从非常容易完成变成潜在的相当具有挑战性:使用多个移动*台增加了复杂性,并且需要使用跳跃的时间和避免攻击敌人——即使是图 12-8 中锁定基本运动模式的简单敌人——以可控和故意的方式创造出几乎无限的可能性。
如果你还没有,现在是一个用代码原型化你的关卡设计(包括交互)来验证游戏性的好时机。对于这个早期的原型,重要的是主要的行为(奔跑、跳跃、发射炮弹、移动*台、物体激活等等)和完成关卡所需的步骤(拼图序列)都要正确实现。一些设计师在这个阶段坚持认为,以前从未遇到过这个关卡的玩家应该能够在很少或没有帮助的情况下玩完整个游戏,并完全理解他们需要做什么,而其他人则愿意提供指导,并填补屏幕上缺失的 UI 和不完整的谜题序列。通常的做法是在这个阶段测试和验证游戏的主要部分,并为玩家提供额外的指导,以弥补不完整的 UI 或序列中未实现的部分。一般来说,在这个阶段,你越不需要依赖玩家的指导,你对整体设计的洞察力就越强。您在这个阶段实现的早期原型的数量也取决于您设计的规模和复杂性。大型和高度复杂的关卡可以在整个关卡可以一次玩完之前分几个(或许多)部分来实现和测试,但是即使在大型和复杂的关卡的情况下,目标也是尽可能早地获得完整的可玩体验。
Note
如果你一直在探索这本书里的工作原型,你会发现在这一章的设计概念和可玩等级之间有一些细微的变化(例如,能量场不包括在工作原型里)。考虑使用包含的资产探索替代设计实现;探索和即兴创作是创造性设计过程的关键要素。你能创建多少个当前机械师的扩展?
第三部分:集成附加设计元素
您在本章中构建的原型可以作为当前开发水*的完整游戏的有效概念证明,但它仍然缺少完整游戏体验通常所需的许多元素(包括视觉细节和动画、声音、评分系统、获胜条件、菜单和用户界面[UI]元素等)。在游戏术语中,原型水*现在处于封锁加阶段(封锁是一个用于描述原型的术语,包括布局和功能游戏,但缺乏其他设计元素;包含一些额外的概念艺术是这里的“加分项”)。现在是开始探索音频、评分系统、菜单和屏幕 UI 等的好时机。如果这个原型是在一个游戏工作室生产的,一个小团队可能会把当前的水*提高到最终的生产水*,同时另一个团队工作来设计和制作其他水*的原型。一个单独的关卡或关卡的一部分被称为垂直部分,这意味着游戏的一个小部分包括了将随最终产品一起发布的所有东西。创建垂直切片有助于团队关注最终体验的外观、感觉和声音,并可用于验证 playtesters 的创意方向。
视觉设计
尽管你已经开始整合一些与设定和叙事相一致的视觉设计资产,但游戏通常在这个时候只有很少(如果有的话)的最终制作资产,任何动画都将是粗糙的或尚未实现的(游戏音频也是如此)。虽然让游戏性与游戏设置并行发展是一个很好的做法,但工作室不希望浪费时间和资源来创建制作资产,直到团队确信关卡设计已经锁定,并且他们知道需要什么对象以及它们将被放置在哪里。
你现在应该有了一个相当好的关卡设计的布局和序列(如果你一直在试验一个与示例中所示不同的布局,确保你有一个完整的游戏流程,如图 12-1 和 12-6 所示)。)在项目的这一点上,你可以自信地开始“重新调整”生产资产(中的重新调整是游戏工作室使用的一个术语,意思是随着时间的推移增加分辨率——在这种情况下,是关卡的视觉效果和整体生产质量)。重新分区通常是一个多阶段的过程,从关卡设计的主要元素被锁定时开始,并且可以持续大部分活动生产计划。通常有数百个(或数千个)单独的资产、动画、图标等,它们通常需要基于它们在游戏构建外部和游戏构建内部的不同而被调整多次。在孤立和实体模型中看起来很和谐的元素,在整合到游戏中后往往会有很大的不同。
*重新划分资产的过程可能是乏味和令人沮丧的(资产似乎总是比你想象的要多一个数量级)。让游戏中的东西看起来像艺术家的模型一样棒也是一个挑战。然而,当这一切开始走到一起时,这通常是一种令人满意的体验:当关卡设计从封闭过渡到完美的生产关卡时,神奇的事情发生了,通常会有一个构建,其中一些关键的视觉资产已经进来,使团队评论说:“哇,现在这感觉像我们的游戏!”对于 AAA 3D 游戏,这些“哇”的时刻经常发生,因为高分辨率纹理被添加到 3D 模型中,复杂的动画、灯光和阴影使世界变得栩栩如生;对于目前的原型水*,添加*行的背景和一些局部的灯光效果应该真的使飞船设置流行。
这本书包含的工作原型代表了最终游戏的构建,通常介于封锁和成品润色之间。英雄角色包括几个动画状态(空闲、奔跑、跳跃),英雄和机器人上的局部照明增加了视觉兴趣和戏剧性,关卡具有两层*行*行背景,法线贴图对照明做出响应,主要游戏行为都在适当的位置。你可以在这个原型的基础上继续完善游戏,或者按照你认为合适的方式修改它。
游戏音频
许多新的游戏设计师(甚至一些资深设计师)错误地认为音频没有视觉设计重要,但正如每个游戏玩家都知道的那样,在某些情况下,糟糕的音频可能意味着你喜欢的游戏和你很快就停止玩的游戏之间的差异。与视觉设计一样,音频通常直接有助于游戏机制(例如,倒计时定时器、警笛、指示敌人位置的位置音频),背景音乐增强了戏剧性和情感,就像导演使用乐谱来支持电影中的动作一样。然而,手机游戏中的音频通常被认为是可选的,因为许多玩家在他们的移动设备上静音。然而,设计良好的音频甚至可以对手机游戏的临场感产生巨大的影响。除了与游戏对象相对应的声音(行走的角色的行走声音,开火的敌人的射击声音,弹出的东西的弹出声音等等),游戏内动作附带的上下文音频是玩家重要的反馈机制。菜单选择、激活游戏中的开关等都应该评估潜在的音频支持。作为一般规则,如果游戏中的对象响应玩家的交互,应该评估它的上下文音频。
音频设计师与关卡设计师一起工作,创建一个需要声音的游戏对象和事件的综合回顾,随着视觉效果的重新调整,相关的声音通常会随之而来。游戏声音经常落后于视觉设计,因为音频设计师想知道他们创造声音的目的;例如,如果你看不到机器人长什么样,也看不到它是如何移动的,就很难创造出“机器人行走”的声音。就像设计师希望将游戏设置和机械紧密结合一样,音频工程师希望确保视觉和音频设计能够很好地协同工作。
交互模型
目前的原型使用一种常见的交互模型:键盘上的 A 和 D 键左右移动角色,空格键用于跳跃。世界中的物体激活仅仅是通过英雄人物与物体的碰撞而发生的,对于这些交互来说,设计的复杂性是相当低的。然而,想象一下,当你继续构建这个机制时(也许在后面的关卡中),你包括了角色发射射弹和收集游戏物品以储存在库存中的能力。随着游戏中可能的交互范围的扩大,复杂性可能会急剧增加,并且无意的挑战(如前所述)可能会开始积累,这可能会导致坏的玩家沮丧(与“好的”玩家沮丧相反,如前所述,好的玩家沮丧是有意设计的挑战的结果)。
了解在不同*台之间调整交互模型时遇到的挑战也很重要。最初为鼠标和键盘设计的交互在转移到游戏控制台或基于触摸的移动设备时通常面临相当大的困难。与游戏控制器不精确的拇指棒相比,鼠标和键盘交互方案允许极其精确和快速的移动,尽管触摸交互可以是精确的,但移动屏幕往往明显较小,并且被覆盖游戏区域的手指所遮挡。该行业花了多年时间和迭代来适应第一人称射击游戏(FPS)类型,从使用鼠标和键盘到游戏控制台,并且在第一次移动 FPS 体验推出十多年后,触摸设备的 FPS 惯例仍然高度可变(部分是由于市场上许多手机和*板电脑的处理能力和屏幕尺寸的差异)。如果你计划开发一款跨*台的游戏,确保你在开发游戏时考虑了每个*台的独特需求。
游戏系统和元游戏
目前的原型有几个系统需要*衡,也没有整合元游戏,但想象一下添加需要*衡的元素,如对象激活或能量场的可变长度计时器。如果你不确定这意味着什么,考虑下面的场景:英雄人物有两种潜在的方法去激活能量场,每种选择都是一种权衡。第一个选项可能会永久停用能量场,但会产生更多的敌人机器人,并大大增加到达目标对象的难度,而第二个选项不会产生更多的机器人,但只会在短时间内停用能量场,这需要玩家选择最有效的路径并执行*乎完美的计时。为了在这两个选项之间取得有效的*衡,您需要理解与每个系统相关的设计和挑战程度(无限时间和有限时间)。类似地,如果你给英雄角色增加生命值,让射击机器人创造 x 的伤害量,而冲锋的仆从每击创造 y 的伤害量,你会想要理解通往目标的路径之间的相对权衡,也许会使一些路径不那么危险但导航更复杂,而另一些路径可能导航更快但更危险。
与当前设计的大多数其他方面一样,在元游戏的开发中有许多方向可以选择;当玩家玩一个原型级别风格的完整游戏时,你能为他们提供什么额外的正面强化或总体环境?举一个例子,想象玩家必须收集一定数量的物体才能进入最终区域,并防止船只爆炸。也许每一关都有一个对象,要求玩家在进入之前解决某种谜题,只有在收集到该对象后,他们才能解决该关的开门组件。或者,也许每个级别都有一个对象,玩家可以访问以解锁电影,并了解更多关于船上发生的事情,以达到如此可怕的状态。或者也许玩家能够以某种方式禁用敌人的机器人并收集分数,目标是在游戏结束时收集尽可能多的分数。也许你会选择完全放弃传统的输赢条件。游戏并不总是将明确的输赢条件作为元游戏的核心组成部分,对于越来越多的当代游戏,尤其是独立游戏,它更多的是关于旅程而不是竞争体验(或者竞争元素变得可选)。也许你可以找到一种方法,将竞争性方面(例如,获得最高分或在最短时间内完成每一关)和更注重提高游戏性的元游戏元素结合起来。
关于系统和元游戏的最后一点说明:玩家教育(通常通过游戏内教程实现)通常是这些过程的重要组成部分。设计者非常熟悉他们设计的机制是如何工作的,控制是如何工作的,并且很容易(也很常见)忘记游戏对于第一次遇到它的人会是什么样子。早期和频繁的游戏测试有助于提供关于玩家需要多少解释才能理解他们需要做什么的信息,但是大多数游戏需要某种程度的教程支持来帮助教授游戏世界的规则。教程设计技术超出了本书的范围,但是当玩家玩一个或多个入门关卡时,教他们游戏的逻辑规则和交互通常是最有效的。向玩家展示你想让他们做的事情也比让他们阅读大段文字更有效(研究表明,许多玩家从不访问可选教程,并且会在不阅读的情况下解雇文本过多的教程;每个辅导活动一两个非常简短的句子是一个合理的目标)。如果你正在为你的原型创建一个内部教程系统,你将如何实现它?你认为玩家自己会合理地发现什么,而你可能需要在教程中向他们展示什么?
用户界面(UI)设计
游戏 UI 设计不仅从功能的角度(游戏中的菜单、教程和上下文相关的重要信息,如健康、分数等)来看很重要,而且作为体验的整体设置和视觉设计的贡献者也很重要。游戏 UI 是视觉游戏设计的核心组成部分,经常被新设计师忽略,这可能意味着人们喜欢的游戏和没人玩的游戏之间的差异。回想一下你玩过的游戏,这些游戏利用了复杂的库存系统,或者有许多级别的菜单,你必须通过这些菜单才能访问常用功能或物品;你还记得在那些游戏中,你经常需要浏览多个子关卡来完成经常使用的任务吗?或者是需要你记住复杂的按钮组合来访问普通游戏对象的游戏?
优雅且符合逻辑的用户界面对玩家的理解至关重要,但是集成到游戏世界中的用户界面也支持游戏设置和叙事。使用当前的原型和提议的系统设计作为参考,你将如何以一种支持设定和美学的方式可视化地表现游戏 UI?如果你以前没有花时间评估 UI(即使你有),重新访问几个具有科幻设置的游戏,并特别注意它们如何在游戏屏幕中视觉上集成 UI 元素。图 12-9 显示了内脏游戏的死亡空间 3 中的武器定制 UI:注意界面设计是如何完全嵌入到游戏设定中的,表现为虚拟飞船上的信息屏幕。
图 12-9
内脏游戏《死亡空间 3》中的大部分用户界面元素完全呈现在游戏场景和小说中,菜单以全息投影的形式出现,由英雄角色调用,或者出现在游戏世界中的物体上(图片版权归电子艺界所有)
许多游戏选择将它们的 UI 元素放在游戏屏幕的保留区域(通常在外部边缘周围),这些区域不与游戏世界直接交互;然而,将视觉美感与游戏设置相结合是另一种直接促成游戏存在的方式。想象一下当前的科幻原型例子,它有一个以幻想为主题的用户界面和菜单系统,使用中世纪的美学设计和书法字体,用于像 Bioware 的龙腾世纪这样的游戏;由此产生的不匹配将是不和谐的,很可能会把玩家从游戏环境中拉出来。用户界面设计是一门复杂的学科,很难掌握;然而,花时间确保将直观、可用、美观的 UI 集成到你创建的游戏世界中,会对你有好处。
游戏叙事
在这个阶段,您只是向原型示例添加了一个基本的叙事包装:一个英雄角色必须完成许多目标,以防止他们的飞船爆炸。目前,你还没有明确地与玩家分享这个故事,他们没有办法知道环境是在一艘宇宙飞船上,或者除了最终打开屏幕最右边的门之外的目标是什么。设计师有很多向玩家展示游戏叙事的选择;您可以创建一个介绍性的电影或动画序列,向玩家介绍英雄人物、他们的飞船和危机,也许可以选择一些简单的东西,如在关卡开始时弹出一个窗口,其中有简短的介绍文本,为玩家提供所需的信息。或者,你可以不提供任何关于游戏开始时发生了什么的信息,而是选择随着玩家在游戏世界中的进展,慢慢揭示船的可怕情况和目标。你甚至可以选择保留任何隐含的叙事元素,允许玩家覆盖他们自己的解释。与游戏设计的许多其他方面一样,没有单一的方式向玩家介绍叙事,也没有通用的指南来说明要获得令人满意的体验需要多少叙事。
叙事也可以被设计师用来影响关卡的发展和构建方式,即使这些元素从来没有向玩家展示过。在这种原型的情况下,它有助于设计师想象爆炸船只的威胁,以推动英雄人物带着紧迫感通过一系列挑战;然而,玩家可能会体验到一个结构良好的侧滚动作*台,只有一系列非常聪明的关卡。你可以围绕感染了病毒的机器人创造额外的虚构故事,使它们转而攻击英雄,作为它们攻击行为的一个原因(这只是一个例子)。通过创建一个叙事框架来展开动作,你可以做出明智的决定来扩展机制,即使你不与玩家分享所有的背景,也会感觉很好地融入到设置中。
当然,一些游戏体验实际上没有明确的叙事元素,无论是否向玩家公开,只是简单地实现了新颖的机制。像 Zynga 的 Words with Friends 和 Gabriele Cirulli 的超休闲 2048 这样的游戏是纯粹基于没有叙事包装的机制的游戏体验的例子。
如果你继续开发这个原型,你会选择包含多少叙事,你会向玩家透露多少以使游戏变得生动?
奖励内容:在关卡中增加第二个阶段
如果你已经完成了包括原型的第一阶段,你将进入第二个房间,有一个大的移动单元;这是一个沙盒,有一组资产供您探索。原型实现只包括一些激发你想象力的基本行为:一个大型的动画 boss 单元在房间里盘旋,产生一种新的敌人机器人,它寻找英雄角色,每隔几秒钟就产生一个新的单元。
图 12-10 显示了一个你一直用于原型基本机械的布局。
图 12-10
一个可能的第二阶段,英雄人物可以在第一阶段打开门后进入。这个概念包括一个具有三个节点的大型“boss”单元;此阶段的一个目标可能是禁用每个节点以关闭 boss
从图 12-10 中的图表开始机械探索是一条捷径,但是因为你已经确定了设置和一些视觉元素,用一些已经存在的视觉资产继续开发新的舞台会很有帮助。该图包括第一阶段中使用的相同类型的*台,但如果,例如,这个区域没有重力,英雄人物能够自由飞行,会怎么样?将这一区域与第一阶段进行比较,并思考如何在不从根本上改变游戏的情况下,稍微改变一下体验,将事情混合起来;理想情况下,你已经相当熟练地掌握了第一阶段的测序机制,第二阶段的经历可能是该机制或多或少的演变。
如果你选择包括寻找英雄的飞行机器人单位,游戏流程图将变得比第一阶段使用的模型更复杂,因为新机器人类型的移动不可预测。你可能还想考虑一个机制让英雄角色消灭机器人单位(也许甚至可以把机器人单位的移除加入到禁用 boss 节点的机制中)。如果你发现你的设计变得难以作为一个明确的和可重复的游戏流程的一部分来描述,这可能表明你正在与更复杂的系统一起工作,可能需要在一个可玩的原型中评估它们,然后你才能有效地*衡它们与关卡的其他组件的集成。当然,你也可以重用阶段 1 中的约定和单元;例如,你可以选择将巡逻机器人与寻找英雄的机器人和一个能量场结合起来,为玩家创建一个具有挑战性的潜在风险网络,让他们在工作中禁用 boss 节点。
你也可以决定这个关卡的主要目标是启用boss 节点以解锁游戏的下一个阶段或关卡。你可以向你喜欢的任何方向扩展故事,因此单位可以是有益的或有害的,目标可以涉及禁用或启用,英雄人物可以奔向某物或远离某物,或者你可以想象的任何其他可能的场景。请记住,叙事发展和关卡设计将相互影响,推动体验向前发展,所以当你越来越熟悉这个原型的关卡设计时,请保持警惕,寻找灵感。
摘要
游戏设计在创意艺术中是独一无二的,因为它要求玩家在体验中成为积极的伙伴,这可能会因玩家的不同而发生巨大的变化。尽管一些游戏与电影有很多相似之处(尤其是随着故事驱动的游戏变得越来越流行),但当玩家或多或少地控制屏幕上的动作时,总会有不可预测的因素。与电影和书籍不同,视频游戏是互动的体验,需要与玩家持续的双向互动,设计糟糕的机制或规则不明确的关卡会阻止玩家享受你创造的体验。
本书介绍的设计方法首先着重于教你设计字母表的字母(基本交互),引导你创造单词(游戏机制和游戏性),然后是句子(关卡);我们希望你能迈出下一步,开始写下一部伟大的小说(现有或全新类型的完整游戏体验)。这里介绍的“逃离房间”设计模板可用于快速原型化多种游戏体验的各种机制,从附带的 2D 侧滚轮到等距游戏,再到第一人称体验等等。请记住,游戏机制从根本上来说是结构良好的抽象谜题,可以根据需要进行调整。如果你发现自己在开始时难以集思广益新的机制,从普通休闲游戏中借用一些简单的现有机制(“比赛 3”的变体是灵感的伟大来源),并从那里开始,随着你的进行添加一两个简单的变体。与任何创造性学科一样,你对基础练习得越多,你对这个过程就越流畅,在你获得一些简单的机械和系统的经验后,你可能会惊讶于你能快速创造的有趣变化的数量。其中一些变化可能会促成下一个突破性的标题。
这本书展示了游戏设计的技术和经验方面的关系。设计师、开发人员、艺术家和音频工程师必须紧密合作,提供最佳体验,在整个制作过程中考虑性能/响应、用户输入、系统稳定性等问题。你在本书中开发的游戏引擎非常适合本章中描述的游戏类型(以及许多其他类型)。现在,您应该已经准备好探索自己的游戏设计,具备强大的技术基础,并对游戏设计的九个要素如何协同工作以创造玩家喜爱的体验有了全面的了解。*