构建你自己的 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 代码中:

以下是使用 ESLint 的一些有用参考:

下载和安装 LiveServer

运行游戏引擎需要 VS 代码的 LiveServer 扩展。它通过 VS 代码在你的计算机上本地启动一个 web 服务器来托管开发的游戏。与 ESLint 非常相似,您可以通过以下步骤安装 LiveServer:

在 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。

技术

以下列表提供了获取本书中使用的技术的其他信息的链接:

二、使用 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:

  1. 创建一个新项目,在你选择的目录下创建一个名为html5_canvas的新文件夹,复制并粘贴你在第一章的前一个项目中创建的index.html文件。

Note

从这一点开始,当要求您创建一个新项目时,您应该遵循前面描述的过程。也就是说,用项目的名称创建一个新文件夹,并复制/粘贴以前项目的文件。这样,您的新项目可以在旧项目的基础上进行扩展,同时保留原有的功能。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2-2

编辑项目中的index.html文件

  1. 在编辑器中打开html5_canvas文件夹,根据需要将其展开,点击index.html文件,打开index.html文件,如图 2-2 所示。

  2. 通过在body元素内的index.html文件中添加以下行来创建用于绘图的 HTML canvas:

<canvas id="GLCanvas" width="640" height="480">
Your browser does not support the HTML5 canvas.
</canvas>

代码用指定的widthheight属性定义了一个名为GLCanvascanvas元素。正如您稍后将体验到的,您将检索对GLCanvas的引用以绘制到该区域中。如果您的浏览器不支持使用WebGL绘图,将显示元素内的文本。

Note

标签<body></body>之间的线被称为“在body元素内”对于本书的其余部分,“在AnyTag元素内”将用于指代元素的开始(<AnyTag>)和结束(</AnyTag>)之间的任何行。

  1. 创建一个包含 JavaScript 编程代码的script元素,同样在body元素中:
<script type="text/javascript">
    // JavaScript code goes here.
</script>

这负责这个项目的 HTML 部分。现在,您将为示例的剩余部分编写 JavaScript 代码:

  1. 通过在script元素中添加以下代码行,在 JavaScript 代码中检索对GLCanvas的引用:
"use strict";
let canvas = document.getElementById("GLCanvas");

Note

JavaScript 关键字定义了变量。

第一行“use strict”是一个 JavaScript 指令,表示代码应该在“严格模式”下执行,其中使用未声明的变量是一个运行时错误。第二行创建一个名为canvas的新变量,并将该变量引用到GLCanvas绘图区域。

Note

所有局部变量名称都以小写字母开头,如canvas

  1. 通过添加以下代码,检索对 WebGL 上下文的引用并将其绑定到绘图区域:
let gl = canvas.getContext("webgl2") ||
         canvas.getContext("experimental-webgl2");

如代码所示,检索到的 WebGL 版本 2 上下文的引用存储在名为gl的局部变量中。通过这个变量,您可以访问 WebGL 2.0 的所有功能。同样,在本书的其余部分,术语 WebGL 将用于指代 web GL 2.0 版 API。

  1. 通过添加以下内容,通过 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%。因此,画布区域被清除为浅绿色。

  1. 通过插入以下代码行,向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

创建新的源代码文件夹

  1. 创建一个名为javascript_source_file的新 HTML5 项目。回想一下,一个新项目是通过创建一个具有适当名称的文件夹,从以前的项目中复制文件,并编辑index.html<Title>元素来反映新项目而创建的。

  2. 鼠标悬停在项目文件夹上,点击新建文件夹图标,在项目文件夹内新建一个名为src的文件夹,如图 2-4 所示。该文件夹将包含您的所有源代码。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2-5

添加新的 JavaScript 源代码文件

  1. 右键单击src文件夹,在src文件夹下新建一个源代码文件,如图 2-5 所示。将新的源文件命名为core.js

Note

在 VS 代码中,您可以使用资源管理器窗口中的右键菜单来创建/复制/重命名文件夹和文件。

  1. 打开新的core.js源文件进行编辑。

  2. 定义引用 WebGL 上下文的变量,并添加允许您访问该变量的函数:

"use strict";
let mGL = null;
function getGL() { return mGL; }

Note

在整个文件或模块中可访问的变量的名字以小写字母“m”开头,如mGL

  1. 定义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()

  1. 定义clearCanvas()函数调用 WebGL 上下文来清空画布绘图区域:

  2. 定义一个函数,在 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 页面:

  1. 打开index.html文件进行编辑。

  2. 像前面的项目一样,创建 HTML 画布GLCanvas

  3. 通过在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.jsindex.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 正方形。

  1. src文件夹中创建一个新的 JavaScript 源文件,并将其命名为vertex_buffer.js

  2. 使用 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()的形式访问该函数。

  1. 声明变量mGLVertexBuffer来存储对 WebGL 缓冲区位置的引用。记得定义一个函数来访问这个变量。

  2. 定义变量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,并且以原点或单位正方形为中心。

  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语句,以便在接下来的步骤中提供对该函数的访问。

  1. 通过使用以下代码导出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 指令组成的简单程序:

  1. 通过打开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,因为这是着色器的通用约定。正如你将看到的,值为VertexShaderid字段允许你识别并加载这个顶点着色器到内存中。

GLSL attribute关键字标识将被传递到 GPU 中的顶点着色器的逐顶点数据。在这种情况下,aVertexPosition属性的数据类型是vec3或者三个浮点数的数组。正如您将在后面的步骤中看到的,aVertexPosition将被设置为引用单位正方形的顶点位置。

gl_Position是一个 GLSL 内置变量,特别是一个包含顶点位置的四个浮点数的数组。在这种情况下,数组的第四个位置将始终是 1.0。代码显示着色器将aVertexPosition转换为vec4,并将信息传递给 WebGL。

  1. 通过在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>

注意不同的typeid字段。回想一下,每个像素调用一次片段着色器。变量gl_FragColor是决定像素颜色的内置变量。在这种情况下,返回颜色(1,1,1,1),即白色。这意味着所有被覆盖的像素将被阴影化为恒定的白色。

有了在index.html文件中定义的顶点和片段着色器,您现在就可以实现编译、链接和加载结果着色器程序到 GPU 的功能了。

编译、链接和加载顶点和片段着色器

为了在逻辑分离的源文件中维护源代码,您将在新的源代码文件shader_support.js中创建着色器支持功能。

  1. 创建一个新的 JavaScript 文件shader_support.js

  2. core.jsvertex_buffer.js文件导入功能:

  3. 定义两个变量mCompiledShadermVertexPositionRef,用于引用着色器程序和 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

  1. 创建一个函数来加载和编译您在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文件中查找着色器源代码,该字段可以是VertexShaderFragmentShader。步骤 B 在 GPU 中创建指定的着色器(顶点或片段)。步骤 C 指定源代码并编译着色器。最后,步骤 D 检查并返回对已编译着色器的引用,如果着色器编译不成功,则抛出错误。

  1. 现在,您可以通过定义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属性的引用。

  1. 定义一个允许激活着色器的函数,以便它可以用于绘制正方形:
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()函数捕捉到顶点缓冲区装载了一个单位正方形的顶点,每个顶点位置包含三个浮点值。

  1. 最后,通过使用export语句导出init()activate()函数,为游戏引擎的其余部分提供对它们的访问:
export { init, activate }

Note

注意,loadAndCompileShader()函数被排除在export语句之外。其他地方不需要这个函数,因此,遵循隐藏本地实现细节的良好开发实践,这个函数应该对这个文件保持私有。

现在已经定义了着色器加载和编译功能。现在,您可以利用并激活这些函数来使用 WebGL 绘图。

使用 WebGL 设置绘图

定义了顶点数据和着色器功能后,现在可以执行以下步骤来使用 WebGL 进行绘制。回想一下之前的项目,初始化和绘图代码是在core.js文件中定义的。现在打开这个文件进行编辑。

  1. vertex_buffer.jsshader_support.js文件导入定义的功能:

  2. 修改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()函数,加载并编译顶点和片段着色器。

  1. 添加一个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 命令。在这种情况下,您发出一个命令,将四个顶点绘制为两个相连的三角形,形成一个正方形。

  1. 现在你只需要修改window.onload函数来调用新定义的drawSquare()函数:

  2. 最后,通过导出getGL()函数,向引擎的其余部分提供对 WebGL 上下文的访问。记住,这个函数是导入的,并且已经被调用来访问vertex_buffer.jssimple_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 类和对象构建抽象

创建该项目的步骤如下:

  1. 创建单独的文件夹来组织游戏引擎的源代码和游戏的逻辑。

  2. 定义一个 JavaScript 类来抽象simple_shader并使用这个类的一个实例。

  3. 定义一个 JavaScript 类来实现一个正方形的绘制,这是目前简单游戏的逻辑。

源代码组织

通过新建一个文件夹,添加一个名为src的源代码文件夹,用 VS 代码新建一个 HTML5 项目。在src内,创建enginemy_game为子文件夹,如图 2-8 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2-8

src文件夹下创建enginemy_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

模块标识符以小写字母开头,例如corevertexBuffer。类名以大写字母开头,例如,SimpleShaderMyGame

  1. src/engine文件夹中创建一个新的源文件,并将该文件命名为simple_shader.js以实现SimpleShader类。

  2. 导入corevertex_buffer模块:

  3. SimpleShader声明为一个 JavaScript 类:

import * as core from "./core.js";
import * as vertexBuffer from "./vertex_buffer.js";

  1. 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关键字定义了一个类的构造函数。

  1. SimpleShader类添加一个方法,以activate着色器进行绘制。再一次,类似于你之前项目中shader_support.jsactivate()功能。

  2. 通过在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 #前缀,因为缺少子类的可见性使得继承中行为的专门化变得复杂。

  1. 最后,为SimpleShader类添加一个导出,这样它就可以在这个文件之外被访问和实例化:
export default SimpleShader;

Note

关键字default表示名称SimpleShader不能被import语句改变。

游戏引擎的核心:core.js

核心包含整个游戏引擎共享的通用功能。这可以包括 WebGL(或 GPU)、共享资源、实用函数等的一次性初始化。

  1. 在新文件夹src/engine下创建一个core.js的副本。

  2. 定义一个函数来创建SimpleShader对象的新实例:

  3. 修改initWebGL()函数,仅关注 WebGL 的初始化,如下所示:

// The shader
let mShader = null;
function createShader() {
    mShader = new SimpleShader(
        "VertexShader",     // IDs of the script tag in the index.html
        "FragmentShader");  //
}

  1. 创建一个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;
    }
}

  1. 修改 clear canvas 函数,将要清除的颜色参数化为:
function init(htmlCanvasID) {
    initWebGL(htmlCanvasID);  // setup mGL
    vertexBuffer.init();      // setup mGLVertexBuffer
    createShader();           // create the shader
}

  1. 导出相关函数供游戏引擎的其余部分访问:
function clearCanvas(color) {
    mGL.clearColor(color[0], color[1], color[2], color[3]);
    mGL.clear(mGL.COLOR_BUFFER_BIT); // clear to the color set
}

  1. 最后,删除window.onload函数,因为实际游戏的行为应该由游戏引擎的客户端定义,或者在本例中,由MyGame类定义。
export { getGL, init, clearCanvas, drawSquare }

src/engine文件夹现在包含了整个游戏引擎的基本源代码。由于对您的源代码进行了这些结构上的更改,游戏引擎现在可以作为一个简单的库,提供创建游戏或简单应用编程接口(API)的功能。目前,您的游戏引擎由三个支持 WebGL 初始化和绘制单位正方形的文件组成:core模块、vertex_buffer模块和SimpleShader类。在剩余的项目中,新的源文件和功能将继续添加到该文件夹中。最终,这个文件夹将包含一个完整而复杂的游戏引擎。然而,这里定义的类似核心库的框架将继续存在。

客户端源代码

src/my_game文件夹将包含游戏的实际源代码。如上所述,这个文件夹中的代码将被称为游戏引擎的客户端。现在,my_game文件夹中的源代码将专注于通过利用您定义的简单游戏引擎的功能来绘制一个简单的正方形。

  1. src/my_game文件夹或者客户端文件夹中新建一个源文件,命名为my_game.js

  2. 按如下方式导入core模块:

  3. MyGame定义为一个 JavaScript 类并添加一个constructor来初始化游戏engine,清除canvas,并绘制正方形:

import * as engine from "../engine/core.js";

  1. 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();
    }
}

  1. 最后,修改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 着色器作为单独的文件加载:

  1. 继续上一个项目,打开simple_shader.js文件,编辑loadAndCompileShader()函数,接收文件路径而不是 HTML ID:

  2. 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 文件夹

  1. src文件夹中新建一个包含所有 GLSL 着色器源代码文件的文件夹,命名为glsl_shaders,如图 2-10 所示。

  2. glsl_shaders文件夹中创建两个新的文本文件,分别命名为simple_vs.glslwhite_fs.glsl,用于简单顶点着色器和白色片段着色器。

Note

所有 GLSL 着色器源代码文件都将以扩展名.glsl结尾。着色器文件名中的vs表示该文件包含顶点着色器,而fs表示片段着色器。

  1. 通过编辑simple_vs.glsl并粘贴前一项目的index.html文件中的顶点着色器代码,创建 GLSL 顶点着色器源代码:

  2. 通过编辑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 代码:

  1. 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文件可以被适当地认为是表示网页,在该网页中,从现在开始,您不需要编辑该文件来修改着色器。

  1. 修改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变量:

  1. 编辑simple_shader.js并添加一个新的实例变量,用于引用构造函数中的uPixelColor:

  2. 将代码添加到构造函数的末尾,以创建对uPixelColor的引用:

this.mPixelColorRef = null; // pixelColor uniform in fragment shader

  1. 修改着色器激活以允许通过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浮点数组复制到由mPixelColorRefsimple_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.jsmy_game.js进行微小的修改。这展示了正确封装和源代码组织的好处。

摘要

至此,游戏引擎很简单,只支持 WebGL 的初始化和一个彩色方块的绘制。然而,通过本章中的项目,你已经获得了为游戏引擎建立良好基础所需的技术经验。您还构建了源代码,通过对现有代码基础的有限修改来支持进一步的复杂性,并且您现在准备进一步封装游戏引擎的功能,以促进附加功能。下一章将关注于在游戏引擎中建立一个合适的框架来支持更灵活和可配置的绘图。

三、世界上的绘画对象

完成本章后,您将能够

  • 创建并绘制多个矩形对象

  • 控制创建的矩形对象的位置、大小、旋转和颜色

  • 定义从中进行绘制的坐标系

  • 在画布上定义要绘制到的目标子区域

  • 使用Renderable对象、变换操作符和相机的抽象表示

介绍

理想情况下,视频游戏引擎应该提供适当的抽象来支持在有意义的上下文中设计和构建游戏。例如,当设计一个足球游戏时,游戏引擎应该提供适当的工具来支持在足球场上奔跑的运动员的设计,而不是具有固定的 1.0 绘图范围的单个正方形。这种高级抽象要求用数据隐藏和有意义的函数封装基本操作,以设置和接收期望的结果。

虽然这本书是关于构建游戏引擎的抽象,但这一章的重点是创建支持绘图的基本抽象。基于足球游戏示例,对在有效游戏引擎中绘图的支持可能包括轻松创建足球运动员、控制他们的大小和方向,以及允许他们在足球场上移动和绘图的能力。此外,为了支持正确的表示,游戏引擎必须允许在画布上绘制特定的子区域,以便可以在不同的子区域显示不同的游戏状态,例如在一个子区域中显示足球场,而在另一个子区域中显示运动员统计数据和得分。

本章确定了基本绘图操作的适当抽象实体,介绍了基于基础数学的运算符来控制绘图,概述了用于配置画布以支持子区域绘图的 WebGL 工具,定义了实现这些概念的 JavaScript 类,并将这些实现集成到游戏引擎中,同时保持了源代码的组织结构。

封装图

尽管绘图能力是游戏引擎最基本的功能之一,但绘图是如何实现的细节通常会分散游戏编程的注意力。例如,在足球比赛中创建、控制位置和绘制足球运动员是很重要的。然而,暴露每个玩家实际上是如何定义的细节(通过形成三角形的顶点的集合)会很快淹没游戏开发过程并使其复杂化。因此,对于游戏引擎来说,为绘图操作提供定义良好的抽象接口是非常重要的。

有了组织良好的源代码结构,就有可能通过对相应文件夹进行本地化更改来实现新概念,从而逐渐地、系统地增加游戏引擎的复杂性。第一个任务是扩展引擎以支持绘图的封装,这样就有可能将绘图操作作为一个逻辑实体或一个可以渲染的对象来操作。

Note

在计算机图形和视频游戏的背景下, render 这个词指的是改变与抽象表示相对应的像素颜色的过程。例如,在前一章中,你学习了如何渲染一个正方形。

可渲染对象项目

这个项目引入了Renderable类来封装绘图操作。在接下来的几个项目中,您将学习更多的支持概念,以细化Renderable类的实现,从而可以创建和操作多个实例。图 3-1 显示了运行可渲染对象项目的输出。这个项目的源代码在chapter3/3.1.renderable_objects文件夹中定义。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-1

运行可渲染对象项目

该项目的目标如下:

  • 重新组织源代码结构以预期功能的增加

  • 支持游戏引擎内部资源共享

  • 通过index.js文件为游戏开发者引入系统化的界面

  • 通过首先抽象相关的绘图功能,开始构建封装绘图操作的类的过程

  • 演示创建多个Renderable对象的能力

源代码结构重组

在向游戏引擎引入额外的功能之前,认识到前一个项目中引擎源代码组织的一些不足是很重要的。特别要注意以下几点:

  1. core.js源代码文件包含 WebGL 接口、引擎初始化和绘图功能。这些应该模块化,以支持系统复杂性的预期增加。

  2. 应该定义一个系统来支持游戏引擎内部资源的共享。例如,SimpleShader负责从游戏引擎到从simple_vs.glslsimple_fs.glsl源代码文件编译的 GLSL 着色器的接口。由于编译后的着色器只有一个副本,所以只需要有一个SimpleShader对象的实例。游戏引擎应该通过允许方便地创建和共享对象来促进这一点。

  3. 正如您所经历的,JavaScript export语句是隐藏详细实现的优秀工具。然而,在一个大型复杂的系统中,例如您将要开发的游戏引擎,确定从大量文件中导入哪些类或模块可能会令人困惑和不知所措,这也是事实。应该提供一个易于操作的系统化界面,使得游戏开发者、游戏引擎的用户可以不受这些细节的影响。

在下一节中,游戏引擎源代码将被重新组织以解决这些问题。

定义特定于 WebGL 的模块

源代码重组的第一步是识别和隔离内部功能,游戏引擎的客户端不应访问这些功能:

  1. 在您的项目中,在src/engine文件夹下,创建一个新文件夹,并将其命名为core。从现在开始,这个文件夹将包含游戏引擎内部的所有功能,并且不会导出给游戏开发者。

  2. 您可以将先前项目中的vertex_buffer.js源代码文件剪切并粘贴到src/engine/core文件夹中。图元顶点的细节是游戏引擎内部的,并且不应该被游戏引擎的客户端看到或访问。

  3. 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.glslsimple_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,客户端可以从引擎访问所有的组件和功能来构建他们的游戏。

  1. src/engine文件夹中创建index.js文件;importgl.jsvertex_buffer.jsshader_resources.js;并定义init()函数,通过调用三个导入模块对应的init()函数来初始化游戏引擎:

  2. 定义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();
}

  1. 现在,为了正确地向游戏引擎的客户端公开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
}

  1. 最后,记住为游戏引擎的客户端导出正确的符号和功能:
// general utilities
import Renderable from "./renderable.js";

export  default {
    // Util classes
    Renderable,

    // functions
    init, clearCanvas
}

通过对这个index.js文件进行适当的维护和更新,游戏引擎的客户端,即游戏开发者,可以简单地从index.js文件导入,以获得对整个游戏引擎功能的访问,而无需了解任何源代码结构。最后,请注意engine/src/core文件夹中定义的glSysvertexBuffershaderResources内部功能不是由index.js导出的,因此游戏开发者无法访问。

可渲染的类

最后,您准备定义Renderable类来封装绘图过程:

  1. 通过在src/engine文件夹中创建一个新的源代码文件来定义游戏引擎中的Renderable类,并将该文件命名为renderable.js

  2. 打开renderable.js,从gl.jsshader_resources.js导入,用构造函数定义Renderable类,初始化对着色器和颜色实例变量的引用。注意,着色器是对在shader_resources中定义的共享SimpleShader实例的引用。

  3. 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 着色器是非常重要的。

  1. 为 color 实例变量定义 getter 和 setter 函数:

  2. 默认导出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构造函数被修改为包括以下步骤:

  1. 步骤 A 初始化engine

  2. 步骤 B 创建了两个Renderable实例,并相应地设置了对象的颜色。

  3. 步骤 C 清除画布;步骤 C1 和 C2 简单地调用了白色和红色方块各自的draw()函数。虽然两个方块都已绘制,但现在,您只能在画布中看到最后一个绘制的方块。详情请参考下面的讨论。

观察

运行项目,你会注意到只有红色方块是可见的!发生的情况是两个方块被画到了同一个位置。由于大小相同,这两个正方形完全重叠在一起。因为红色方块是最后绘制的,所以它会覆盖白色方块的所有像素。您可以通过注释掉红色正方形的绘图(注释掉线条mRedSq.draw())并重新运行项目来验证这一点。一个有趣的观察是出现在前面的物体被画在最后(红色方块)。当你使用透明的时候,你将会利用这个观察。

这个简单的观察引出了您的下一个任务——允许多个Renderable实例同时可见。Renderable对象的每个实例需要支持在不同位置以不同大小和方向绘制的能力,这样它们就不会彼此重叠。

变换可渲染对象

需要一种机制来操纵Renderable对象的位置、大小和方向。在接下来的几个项目中,您将了解如何使用矩阵变换来平移或移动对象的位置,缩放对象的大小,以及在画布上更改对象的方向或旋转对象。这些操作是对象操作中最直观的操作。然而,在实现转换矩阵之前,需要快速回顾一下矩阵的操作和功能。

作为变换运算符的矩阵

在我们开始之前,重要的是要认识到矩阵和变换是数学中的一般主题领域。以下讨论并不试图全面涵盖这些主题。相反,从游戏引擎需要什么的角度来看,重点是相关概念和操作符的小集合。这样,覆盖面是如何利用运营商,而不是理论。如果你对矩阵的细节以及它们与计算机图形的关系感兴趣,请参考第一章中的讨论,在那里你可以通过钻研线性代数和计算机图形的相关书籍来了解更多关于这些主题的内容。

一个矩阵是由一个由 mn 列 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 = ( xyz )必须表示为如下的 4x1 向量:

)

Note

z 分量是顶点位置的第三维或深度信息。大多数情况下,您应该将 z 分量保留为 0。

例如,如果位置p'是平移运算符T对顶点位置p进行运算的结果,那么从数学上讲,p'将通过以下方式计算:

)

矩阵运算符的串联

多个矩阵运算符可以连接或组合成一个运算符,同时保留与原始运算符相同的转换特性。例如,您可能想要在给定的顶点位置上应用缩放操作符S,然后是旋转操作符R,最后是平移操作符T,或者使用下面的

)

来计算p'

或者,您可以通过连接所有的转换操作符来计算一个新的操作符M,如下所示:

)

然后在顶点位置p操作M,如下,产生相同的结果:

)

M操作符是记录和重新应用多个操作符结果的一种方便有效的方式。

最后,注意当使用转换操作符时,操作的顺序很重要。例如,缩放操作后跟随平移操作通常不同于平移后跟随缩放,或者通常:

)

glMatrix 库

矩阵运算符和运算的细节至少可以说是很重要的。开发一个完整的矩阵库很耗时,也不是本书的重点。幸运的是,在公共领域中有许多开发良好、记录完善的矩阵库。图书馆就是这样一个例子。要将该库集成到源代码结构中,请按照下列步骤操作:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3-5

下载 glMatrix 库

  1. src文件夹下新建一个文件夹,命名为lib

  2. 进入 http://glMatrix.net ,如图 3-5 所示,将生成的glMatrix.js源文件下载、解压并保存到新的lib文件夹中。

本书所有项目都基于【2.2.2 版。

  1. 作为一个游戏引擎和客户端游戏开发者都必须访问的库,您将通过在加载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 上下文传入所有顶点的地方,也是应用变换操作的最方便位置。

您将继续使用之前的项目来支持顶点着色器中的变换操作符:

  1. 编辑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

  1. main()功能中,将uModelXformMatrix应用到当前参考的顶点位置:
gl_Position = uModelXformMatrix * vec4(aVertexPosition, 1.0);

请注意,该运算直接来自对矩阵变换运算符的讨论。将aVertexPosition转换为vec4的原因是为了支持矩阵向量乘法。

通过这个简单的修改,单位正方形的顶点位置将由uModelXformMatrix操作符操作,因此正方形可以被绘制到不同的位置。现在的任务是设置SimpleShader将适当的转换操作符加载到uModelXformMatrix中。

修改 SimpleShader 以加载变换运算符

请遵循以下步骤:

  1. 编辑simple_shader.js并添加一个实例变量来保存对顶点着色器中uModelXformMatrix矩阵的引用:

  2. 在步骤 E 下的SimpleShader构造函数的末尾,将引用设置为uPixelColor后,添加以下代码来初始化该引用:

this.mModelMatrixRef = null;

  1. 修改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.mModelMatrixRefuModelXfromMatrix操作符确定。变量的名字trsMatrix表明它应该是一个矩阵运算符,包含平移(T)、旋转(R)和缩放(STRS)的级联结果。

修改可呈现类以设置变换运算符

编辑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将包含适当的操作符,用于转换顶点,从而在所需的位置、大小和旋转角度绘制正方形。

测试转换

既然游戏引擎支持变换,您需要修改客户端代码来使用它进行绘制:

  1. 编辑my_game.js;在步骤 C 之后,代替激活和绘制两个正方形,替换步骤 C1 和 C2 来创建一个新的恒等式变换操作符,trsMatrix:

  2. 将矩阵连接到一个变换操作符,该操作符实现平移(T)、旋转(R)和缩放(STRS):

// 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对象。

  1. 最后,步骤 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对象

转换类

继续使用上一个项目:

  1. 通过在src/engine文件夹中创建一个新的源代码文件来定义游戏引擎中的Transform类,并将该文件命名为transform.js

  2. 定义构造函数来初始化对应于操作符的实例变量:mPosition用于平移,mScale用于缩放,mRotationInRad用于旋转。

  3. 为每个运算符的值添加 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 ...
}

  1. 定义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首先执行缩放,然后是旋转,最后是平移。

  1. 最后,记得导出新定义的Transform类:
export default Transform;

可转换的可呈现类

通过集成Transform类,Renderable对象现在可以有位置、大小(缩放)和方向(旋转)。这种集成可以通过以下步骤轻松完成:

  1. 编辑renderable.js并添加一个新的实例变量来引用构造函数中的Transform对象:

  2. 为转换运算符定义一个访问器:

this.mXform = new Transform();     // transform operator for the object

  1. 修改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类:

  1. 编辑index.js;从新定义的transform.js文件导入:

  2. 导出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 和相应的绘图视口。第一步是修改着色器以支持新的变换操作符。

修改顶点着色器以支持相机变换

添加对相机变换的支持需要相对较小的更改:

  1. 编辑simple_vs.glsl添加一个新的uniform矩阵操作符来表示摄像机变换:

  2. 确保在顶点着色器程序中对顶点位置应用操作符:

uniform mat4 uCameraXformMatrix;

gl_Position = uCameraXformMatrix *
              uModelXformMatrix *
              vec4(aVertexPosition, 1.0);

回想一下,矩阵运算的顺序很重要。在这种情况下,uModelXformMatrix首先将顶点位置从模型空间转换到 WC,然后uCameraXformMatrix从 WC 转换到 NDC。uModelxformMatrixuCameraXformMatrix的顺序不能互换。

修改 SimpleShader 以支持相机变换

必须修改SimpleShader对象,以访问相机变换矩阵并将其传递给顶点着色器:

  1. 编辑simple_shader.js,并在构造函数中添加一个实例变量,用于存储对simple_vs.glsl中摄像机变换操作符的引用:

  2. SimpleShader构造函数的末尾,在检索了对uModelXformMatrixuPixelColor的引用之后,检索对摄像机变换操作符uCameraXformMatrix的引用:

this.mCameraMatrixRef = null;

  1. 修改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类以实现该设计:

  1. 编辑my_game.js。在构造函数中,执行步骤 A 来初始化游戏引擎,执行步骤 B 来创建六个具有相应颜色的Renderable对象(在中心绘制两个,在 WC 的每个角绘制四个)。

  2. 步骤 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()中涉及的测试计算量很大,因此在使用后会立即禁用。

  1. 步骤 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 定义如下:

  1. 中心 : (20,60)

  2. 左上角 : (10,65)

  3. 右上角 : (30,65)

  4. 右下角 : (30,55)

  5. 左下角 : (10,55)

回想一下,乘法的顺序很重要,缩放和平移运算符的顺序不能互换。

  1. 在 WC 的中心设置一个轻微旋转的 5x5 蓝色方块,并使用相机变换操作符进行绘制,cameraMatrix:

  2. 现在画另外五个正方形,首先是中间的 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 函数来完成。

  1. 通过在src/engine文件夹中创建一个新的源文件来定义游戏引擎中的Camera类,并将该文件命名为camera.js

  2. 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 中心和宽度、视口、摄像机变换操作符和背景颜色。请注意以下几点:

  1. Camera类定义之外,定义访问viewportArray的枚举索引:

  2. mWCCenter是一个vec2 ( vec2glMatrix库中定义)。这是一个由两个元素组成的浮点数组。vec2的第一个元素(索引位置 0)是 x 轴,第二个元素(索引位置 1)是 y 轴。

  3. viewportArray的四个元素依次是左下角的 x 和 y 位置以及视口的宽度和高度。视口的这种紧凑表示将实例变量的数量保持在最小,并有助于保持Camera类的可管理性。

  4. mWCWidth是 WC 的宽度。为了保证 WC 和视口之间的纵横比匹配,WC 的高度总是根据视口和mWCWidth的纵横比来计算。

  5. mBgColor是一个由四个浮点数组成的数组,代表一种颜色的红、绿、蓝和 alpha 分量。

const eViewport = Object.freeze({
    eOrgX: 0,
    eOrgY: 1,
    eWidth: 2,
    eHeight: 3
});

Note

枚举元素的名字以小写字母“e”开头,如eViewporteOrgX

  1. 定义基于视口纵横比计算 WC 高度的函数:

  2. 为实例变量添加 getters 和 setters:

getWCHeight() {
    // viewportH/viewportW
    let ratio = this.mViewport[eViewport.eHeight] /
                this.mViewport[eViewport.eWidth];
    return this.getWCWidth() * ratio;
}

  1. 创建一个函数来设置视口并计算该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 的细节。

  1. 在步骤 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对实例变量的正确引用。

  1. 在步骤 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构造函数。

  1. 定义一个函数来访问计算出的摄像机矩阵:

  2. 最后,记得导出新定义的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类:

  1. 编辑index.js;从新定义的camera.js文件导入:

  2. 导出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开始测试就很简单了:

  1. 编辑my_game.js;在步骤 A 中初始化游戏引擎之后,创建一个Camera对象的实例,其设置定义了步骤 B 中的前一个项目的 WC 和视口:

  2. 继续创建六个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 ...
}

  1. 现在,调用中的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 提供的视窗功能来实现。

当您构建绘图系统时,游戏引擎源代码结构一直被重构为抽象和封装的组件。这样,源代码结构继续支持进一步的扩展,包括下一章将讨论的附加功能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值