当前位置: 首页 > news >正文

Three-js-游戏开发-全-

Three.js 游戏开发(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Three.js 是一个易于学习的网络 3D 图形库。这本书解释了 Three.js API 以及如何使用它来构建沉浸式在线游戏。当你完成这本书时,你将能够通过他们的网络浏览器触及数百万玩家,并且你将在构建过程中创建如第一人称射击游戏等令人兴奋的项目。

我已经制作游戏超过十年了。当我发现 Three.js 时,我第一个构建的项目非常类似于你将在第三章中构建的第一人称射击游戏,名为探索和交互。我被它能快速创建有趣游戏而无需对库有任何先前的了解所吸引。

使用 Three.js 进行游戏开发中,我试图保持那种探索精神。我希望你在阅读这本书时能像我写作时一样享受乐趣。

这本书涵盖的内容

第一章,你好,Three.js,描述了 Three.js 是什么以及它能做什么,如何用它开始编写代码,以及一个基本场景。

第二章,构建世界,解释了 Three.js 场景的组成部分,包括渲染器、几何体、材质和照明,用于构建一个过程生成的城市。

第三章,探索和交互,解释了鼠标和键盘交互、基本物理以及创建第一人称射击游戏。

第四章,添加细节,解释了粒子系统、声音、图形效果,以及除了构建夺旗游戏外,如何管理外部资产,如 3D 模型。

第五章,设计和开发,描述了网络游戏设计,包括开发流程、性能考虑因素和网络基础。

你需要这本书的内容

你需要一个网络浏览器。为了完全体验这本书中讨论的所有功能,请使用 Chrome 22 或更高版本或 Firefox 22 或更高版本。Internet Explorer 11 或更高版本也应该可以工作。还推荐使用文本编辑器,特别是如果你不使用 Chrome,如第一章,你好,Three.js中所述。在书的某些部分,你需要一个互联网连接,例如下载 Three.js 库时(这些点将在文本中标识)。

这本书面向的对象

这本书是为对网络 3D 游戏编程感兴趣的人而写的。假设读者对 JavaScript 语法有基本的熟悉度,并对 HTML 和 CSS 有基本的了解。假设没有 Three.js 的先验知识。无论你是否有游戏编程的经验,无论你打算构建休闲的副项目还是大规模的专业游戏,这本书都应该是有用的。

习惯用法

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“THREE变量是全局的。”

代码块设置为如下:

renderer = new THREE.CanvasRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

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

renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“如果您想尝试仍在开发中的 WebGL 功能,您可以在 Canary 的about:flags页面上启用其中的一些。”

注意

警告或重要注意事项以如下框中显示。

小贴士

小贴士和技巧看起来像这样。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。

要向我们发送一般反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/8539OS_Images.pdf下载此文件。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的任何现有勘误列表中。您可以通过选择您的标题从 www.packtpub.com/support 查看任何现有勘误。

盗版

在互联网上,版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现了我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

如果您发现了疑似盗版材料,请通过 <copyright@packtpub.com> 联系我们,并提供链接。

我们感谢您在保护我们作者以及为我们带来有价值内容方面的帮助。

问题

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. 欢迎来到 Three.js

本章将带你通过一个新的 Three.js 项目从零开始。我们将介绍 Three.js 是什么,如何开始为其编写代码,以及基本场景的组成部分。

三维.js 的奇妙世界

Three.js 是一个简化在网页浏览器中显示 3D 图形的 JavaScript 库。艺术家、大型品牌以及许多人越来越多地使用 Three.js 来提供可以触及数百万人的沉浸式在线体验,这些体验可以在许多平台上运行。在threejs.org/上可以找到许多令人鼓舞的技术演示。

Three.js 通常与一种名为WebGL的新技术一起使用,这是一个用于渲染图形而不需要插件的 JavaScript API。该 API 基于OpenGL,一个桌面图形 API(GL代表图形库)。因为它使用客户端的图形处理单元来加速渲染,所以 WebGL 非常快!然而,许多移动浏览器以及 IE 10 及以下版本不支持 WebGL。幸运的是,Three.js 还支持使用HTML5 Canvas API以及其他技术,如可缩放矢量图形进行渲染。

注意

你可以在caniuse.com/webgl找到有关浏览器支持的最新信息。

Three.js 最初由Ricardo Cabello编写和维护,他也被称为Mr.Doob。该库是开源的(MIT 许可),可以从其 GitHub 页面获取,github.com/mrdoob/three.js。Three.js 的文档可在网上找到,threejs.org/docs/。当文档不足时,最佳查看位置是项目的examples文件夹,其中包含大量演示不同功能的示例。你可以在threejs.org/examples/上在线浏览这些示例。如果你需要了解某个类的实现方式或它公开的方法和属性,src文件夹中的源代码也值得浏览。开发者们在问答网站StackOverflow上回答有关 Three.js 的问题,所以如果你对某事感到困惑,你可以浏览带有three.js标签的问题或提出自己的问题。

小贴士

本书使用 Three.js 项目的版本 r61 编写。API 的某些部分仍在开发中,但任何可能发生变化的内容将在引入时指出。

让我们编码!

由于 Three.js 在网页浏览器中运行,它可以在许多不同的平台上运行和开发。实际上,我们将直接在浏览器中构建我们的第一个 Three.js 项目!

打开mrdoob.com/projects/htmleditor/。你应该会看到 HTML 和 JavaScript 代码叠加在一个旋转的球形形状之上,如下面的截图所示:

让我们编码!

在线 Three.js 编辑器

这就是 Three.js 的Hello, World程序——在浏览器中渲染旋转形状所需的最小代码。当你更改任何代码时,预览将自动更新,所以你可以尝试玩一玩,看看会发生什么。例如,尝试将THREE.MeshBasicMaterial更改为THREE.MeshNormalMaterial。如果你将IcosahedronGeometry更改为TorusKnotGeometry会发生什么?尝试调整一些数字。你能让形状旋转得更快或更慢吗?

已见,场景已见

让我们更深入地了解我们的旋转形状世界,并解释它是如何工作的。你可以在这个在线编辑器中跟随本节,或者将代码输入到一个新的 HTML 文件中。

首先,是 HTML 模板:

<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>body {background-color: #ffffff;margin: 0;overflow: hidden;}</style></head><body><script src="img/three.min.js"></script><script> /* …your code here… */ </script></body>
</html>

这里没有什么令人惊讶的。我们基本上只是包含了 Three.js,并移除了浏览器默认的页面边距。《canvas》元素,我们将渲染场景到这个元素上,将通过我们的 JavaScript 添加到 DOM 中。

注意

而不是使用来自 GitHub CDN 的 Three.js 文件,你应该下载最新的 Three.js 构建版本,并将本地副本包含到你的项目中。完整的 Three.js 脚本可以在项目的build文件夹中找到,或者可以从raw.github.com/mrdoob/three.js/master/build/three.js下载。在生产环境中,你将想要使用压缩版本(three.min.js)。

现在是时候来点有趣的:告诉 Three.js 显示一些内容。首先,让我们声明我们将需要的对象:

var camera, scene, renderer;
var geometry, material, mesh;

然后,让我们给它们赋值并解释它们的作用:

  scene = new THREE.Scene();

Scene类表示影响屏幕上显示内容的对象列表,例如 3D 模型和灯光。(Three.js 提供的每个类都是作为全局THREE变量的一个属性调用的。)一个场景本身并不很有用,所以让我们往里面放些东西。

提示

下载示例代码

你可以从你购买的所有 Packt 书籍的账户中下载你购买的所有示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

一个mesh对象可以在场景中显示,使用THREE.Mesh构造函数实例化。它由geometry组成,这是对象的形状,以及一个material,这是一个颜色、图像或其他纹理,它定义了形状的面的外观。在这种情况下,我们将使用的几何形状是IcosahedronGeometry,它基于一个 20 边形,近似于球体。构造函数接受一个半径和细节,其中细节是分割 20 面体的每条边的次数,以添加更多面并使形状更接近球体:

  geometry = new THREE.IcosahedronGeometry(200, 1);material = new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true, wireframeLinewidth: 2 });mesh = new THREE.Mesh(geometry, material);

MeshBasicMaterial 是一种不受周围光照影响的材质。我们传递的选项包括十六进制格式的颜色(就像你在 CSS 中使用的那样),是否显示形状为纯色或突出边缘,以及绘制线框的厚度。

小贴士

有许多其他类型的几何形状和材料。第二章 构建世界 详细描述了它们。

现在我们可以将我们的网格添加到场景中:

  scene.add(mesh);

我们已经组装了我们想要显示的内容,所以下一步就是实际显示它。Three.js 通过 渲染器 完成此操作,它对场景中的对象进行一些计算,然后要求浏览器以特定格式(如 WebGL)显示结果。默认情况下,渲染器创建一个新的 <canvas> 元素,应该添加到 DOM 中:

  renderer = new THREE.CanvasRenderer();renderer.setSize(window.innerWidth, window.innerHeight);document.body.appendChild(renderer.domElement);

在这里,我们使用 CanvasRenderer 作为显示场景的方法。(我们将在 第二章 构建世界 中介绍其他渲染器,如 WebGLRenderer。)我们还通过 setSize() 调用告诉渲染器以浏览器窗口的全尺寸显示场景。然后我们将渲染器的画布添加到 DOM 中,使用 appendChild(renderer.domElement)

小贴士

避免使用 CSS 改变画布的大小;使用渲染器的 setSize 方法代替,该方法设置画布元素的 widthheight HTML 属性。这是因为 CSS 描述的是显示大小,而不是渲染大小。也就是说,如果画布以 800 x 600 的分辨率渲染,但 CSS 显示为 1024 x 768,渲染将被拉伸以填充空间,就像你指定了比实际大小更大的 CSS 图像大小一样。这可能会导致扭曲,并难以在“屏幕空间”和“画布空间”之间进行转换。

我们最后需要的是一个 camera 对象,如下面的代码片段所示,这是 Three.js 使用它来告诉渲染器场景应该从哪个视角显示。如果玩家站在你的虚拟世界中,他们的屏幕代表他们能看到的内容,那么 camera 就是他们的眼睛,renderer 就是他们的大脑,而 scene 就是他们的宇宙。

  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 1000);camera.position.z = 500;

PerspectiveCamera 实例从空间中的一个单独点显示世界,就像你的眼睛一样。这由于距离产生了一点点扭曲(远离的物体看起来更小)。还有一个 OrthographicCamera 类,就像从平面上向外看。正交相机有时用于 等距(也称为 2.5D)游戏和关卡编辑器,以获得物体相对尺寸的准确视图。你可以在以下图中看到差异:

去过那里,场景如此

摄像机投影。上方是透视投影,下方是正交投影。

PerspectiveCamera 对象的参数是视野(以度为单位),它控制着摄像机镜头的宽度;纵横比,画布宽度与高度的比率;近平面视锥体,物体可以离摄像机多近仍然可见;以及远平面视锥体,物体可以离摄像机多远仍然可以被渲染。你很少需要更改这些值

还要注意,我们通过将 camera.position.z 赋值来改变摄像机的位置。Three.js 使用一个空间坐标系,其中默认情况下,x 轴从左到右增加,z 轴从后向前增加,y 轴向上增加。大多数对象都有一个 positionscale,它们都由一个三维向量表示(具体为 THREE.Vector3)。它们还有一个由 THREE.Euler 实例表示的 rotation,这是一个抽象,允许将旋转处理得就像向量一样。所有对象都初始化在位置(0, 0, 0),也称为原点。旋转也从(0, 0, 0)开始,缩放从(1, 1, 1)开始。向量非常灵活,但通常你只需要对它们进行 xyz 属性的赋值。例如,如果我们想向上移动摄像机,我们可以增加 camera.position.y

最后,我们可以通过请求渲染器从摄像机的视角显示场景来展示场景:

  renderer.render(scene, camera);

哈喽,一个静态的 3D 显示!如果你一直跟着我们从头开始重建场景,现在是你真正看到你工作成果的时候了。只需在浏览器中打开 HTML 文件。(如果你是从 GitHub 而不是本地加载 three.js 文件,你需要连接到互联网。)

一个静态的场景并不很有趣,所以让我们通过构建一个渲染循环来添加动画:

animate();function animate() {requestAnimationFrame(animate);mesh.rotation.x = Date.now() * 0.00005;mesh.rotation.y = Date.now() * 0.0001;renderer.render(scene, camera);}

这里的关键是 requestAnimationFrame(),当浏览器准备好绘制新帧时,它会执行传递给它的函数。在这个函数中,我们执行对场景的任何必要的更新(在这个例子中,就像我们之前改变摄像机的 position 向量一样,改变网格的 rotation 向量),然后请求渲染器像之前一样重新绘制画布。

将所有内容整合在一起(并且为了清晰起见,将设置代码封装在一个函数中),我们得到:

var camera, scene, renderer;
var geometry, material, mesh;init();
animate();function init() {camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 1, 1000 );camera.position.z = 500;scene = new THREE.Scene();geometry = new THREE.IcosahedronGeometry( 200, 1 );material = new THREE.MeshBasicMaterial( { color: 0x000000, wireframe: true, wireframeLinewidth: 2 } );mesh = new THREE.Mesh( geometry, material );scene.add( mesh );renderer = new THREE.CanvasRenderer();renderer.setSize( window.innerWidth, window.innerHeight );document.body.appendChild( renderer.domElement );}function animate() {requestAnimationFrame( animate );mesh.rotation.x = Date.now() * 0.00005;mesh.rotation.y = Date.now() * 0.0001;renderer.render( scene, camera );}

它正在动画化!你现在已经在浏览器中构建了你的第一个 3D 世界。因为它是用 JavaScript 编写的,你也可以很容易地将其发送给你的朋友。(在在线编辑器中,点击右上角的堆叠条形图图标 去过那里,场景,然后点击 下载 按钮,并将下载的文件重命名为 .html 扩展名。)

小贴士

在 Three.js 仓库和在线上,您会发现的大多数 Three.js 示例都将所有代码放在一个单独的 HTML 文件中。这对于小型项目来说很方便,但对于大型项目来说则不健康。尽管本书中的大多数代码足够小,可以管理在一个文件中,但我们仍将尝试使用使代码可维护的模式。第五章,设计和开发专门讨论了在规模上表现良好的设计。

选择你的环境

Google Chrome 浏览器通常被认为是 WebGL 支持的领先者,因此许多 Three.js 开发者主要在最新的稳定版 Chrome 或名为 Canary 的 alpha 发布分支中进行工作。Chrome 还有很多其他优点,例如高级性能分析、模拟触摸事件的能力以及支持检查画布帧。(您可以通过 Chrome 开发者工具的设置访问这些功能。画布检查在 www.html5rocks.com/en/tutorials/canvas/inspection/ 上有很好的解释。)如果您想尝试仍在开发中的 WebGL 功能,您可以在 Canary 的 about:flags 页面上启用其中的一些功能。

当涉及到编码时,在线的 Three.js 编辑器非常适合测试小型、孤立的构思,但对于更复杂的项目来说,它会很快变得繁琐。大多数编程环境都有良好的 JavaScript 支持,但有些比其他更好。

Chrome 还有一个适用于某些人的脚本编辑环境。如果您打开 Chrome 开发者工具 (Ctrl / Cmd + Shift + I) 并切换到 Sources 选项卡,您可以将 Chrome 配置为编辑来自您本地文件系统的文件。此环境包括语法高亮、调试、自动完成、压缩文件的源映射、可视化的版本控制以及能够在不重新加载页面的情况下即时运行代码的能力。此外,您还可以存储代码片段以供重用,具体请参阅 developers.google.com/chrome-developer-tools/docs/authoring-development-workflow#snippets

你可以在下面的屏幕截图中看到编辑器的样子:

选择你的环境

Google Chrome 开发者工具

如果你更喜欢在 Chrome 编辑器之外工作,频繁切换窗口和重新加载页面可能会很麻烦。有几个工具试图解决这个问题。LiveReload (livereload.com/) 和 Tin.cr (tin.cr/) 是最知名的;它们是浏览器扩展,当你保存文件时,会自动重新加载页面。你可能还想要尝试 LightTable (www.lighttable.com/),这是一个实验性的 IDE,它也支持自动重新加载,并且还包括了用于可视化管理你的代码的工具。

如果你使用 Sublime Text 作为你的编辑器,你可以通过包管理器或从 Three.js 仓库本身(在/utils/editors)安装 Three.js 命令的自动完成支持。

摘要

我们已经使用 Three.js 构建了我们的第一个 3D 世界。在本章中,我们学习了 Three.js 是什么以及它的功能,回顾了 Three.js 场景的基本组件,并设置了我们的编辑环境。我们首次使用了场景渲染器相机网格几何材质组件。

在下一章中,我们将更详细地介绍这些组件,包括不同类型的渲染器、几何和材质组件。我们还将添加光照效果,制作一个更高级的场景。

第二章:构建一个世界

本章将详细解释 Three.js 场景的组成部分,包括不同的渲染器、几何体、材质和照明。我们还将构建一个程序生成的城市。

几何体

几何体是 THREE.Geometry 的实例,用于定义场景中对象的形状。它们由顶点和面(这些面本身也是对象,可以通过 verticesfaces 数组属性访问)组成。顶点是表示三维空间中点的 THREE.Vector3 对象,而面是表示三角形表面的 THREE.Face3 对象。(所有更复杂的形状都细分成三角形面以进行渲染。)

幸运的是,直接处理顶点和面通常是不必要的,因为 THREE.Geometry 有许多子类可以帮助创建常用的形状。

3D 基本形状

Three.js 提供了生成常见形状的多个类。每个类型的官方文档可在 threejs.org/docs/ 找到,但以下表格显示了常见类型的摘要(省略了一些不常见、可选的构造函数参数):

类型 构造函数 描述
立方体3D 基本形状
THREE.CubeGeometry(width, height, depth, widthSegments = 1, heightSegments = 1,
depthSegments = 1)
它是一个具有指定尺寸的矩形盒子。segments 参数将侧面分割成更小的矩形。
3D 基本形状
THREE.Sphere(radius, horizontalSegments = 8, verticalSegments = 6)
它是通过计算段来创建的球面近似。
多面体(球状体)3D 基本形状
THREE.Icosahedron(radius, detail = 0); 
THREE.Octahedron(radius, detail = 0);
THREE.Tetrahedron(radius, detail = 0);
它是基于具有 20、8 或 4 个边的形状的球面近似;detail 参数指定将每个边分割多少次以创建更多面,使形状更加球形。
圆柱3D 基本形状
THREE.CylinderGeometry(radiusTop, radiusBottom, height, radiusSegments = 8, heightSegments = 1, openEnded = false)
radiusSegments 是连接顶部和底部面的边的数量,沿着曲面向下;heightSegments 是围绕曲面周围的面的环数,如果 openEndedtrue,则圆柱的末端将不会渲染。
环面3D 基本形状
THREE.TorusGeometry(radius, tubeWidth = 40, radialSegments = 8, tubularSegments = 6)
它是一个甜甜圈形状。
环面结3D 基本形状
THREE.TorusKnotGeometry(radius, tubeWidth = 40, radialSegments, tubularSegments, p = 2, q = 3, heightScale = 1)
它是一个类似结的形状,有点像椒盐卷饼。pq 是影响结中扭曲数量的整数。

您可以尝试将上一章中构建的旋转二十面体示例更改为使用不同的几何体,通过将 IcosahedronGeometry 构造函数更改为前面表格中的选项之一来实现。还有一个几何体查看器,位于 threejsplaygnd.brangerbriz.net/gui/,允许您调整构造函数参数以查看结果,还可以复制生成您正在查看的形状所需的代码。

2D 基本形状

Three.js 还提供了以下表格所示默认的二维形状几何体:

类型 构造函数 说明
平面
THREE.PlaneGeometry(width, height, widthSegments = 1, heightSegments = 1)
它是一个具有指定尺寸的矩形。segments参数将平面细分为更小的矩形。
THREE.CircleGeometry(radius, numberOfSides = 8)
它是一个正多边形。
THREE.RingGeometry(innerRadius, outerRadius, radialSegments = 8, ringSegments = 8)
它是一个中间有孔的圆。

这些形状默认沿 x 和 y 轴创建。

此外,Three.js 可以创建线条。你通常会在场景中放置的所有对象几乎都是网格,但线条是例外。考虑以下代码,它创建了一个网格:

geometry = new THREE.IcosahedronGeometry(200, 2);
material = new THREE.MeshBasicMaterial({color: 0x000000});
mesh = new THREE.Mesh(geometry, material);

而不是使用前面的代码,你可以使用以下代码片段来创建一条线:

geometry = new THREE.IcosahedronGeometry(200, 2);
material = new THREE.LineBasicMaterial({color: 0x000000});
mesh = new THREE.Line(geometry, material);

这可能会为标准几何体(如IcosahedronGeometry)产生一些奇怪的结果,因为线条将以意想不到的顺序连接点。相反,你通常会想创建一个自定义几何体,这样你就可以按照你想要的顺序添加顶点。

提示

使用LineDashedMaterial而不是LineBasicMaterial来创建虚线。

自定义几何体

几种默认的几何体类型允许根据开发者特别创建的顶点或路径创建形状。(你还可以导入在外部程序中创建的几何体,这是第四章添加细节中讨论的主题。)最基本类型是THREE.Geometry类本身。例如,你可以使用以下代码片段创建一个三角形:

var geo = new THREE.Geometry();
geo.vertices = [new THREE.Vector3(0, 0, 0),new THREE.Vector3(0, 100, 0),new THREE.Vector3(0, 0, 100)
];
geo.faces.push(new THREE.Face3(0, 1, 2));
geo.computeBoundingSphere();

首先,这段代码创建了一个没有任何顶点或面的几何对象。然后,它添加了特定的顶点,其中每个顶点由一个表示在 x、y 和 z 轴上空间坐标的THREE.Vector3表示。接下来,将一个THREE.Face3添加到faces数组中。Face3构造函数的参数指示在几何体的vertices数组中使用的顶点索引,用于构成面的角。最后,计算边界球体,这会触发 Three.js 需要跟踪的内部计算,例如形状是否在视图中。如果你在自定义材质上正确显示纹理有困难,你可能还需要调用geo.computeFaceNormals()geo.computeVertexNormals()。这些函数计算关于几何体视觉布局的附加信息。

手动创建由单个顶点组成的形状会很快变得令人疲倦;然而,一些实用工具存在以帮助使这个过程更快,如下表所示:

几何体 描述
THREE.LatheGeometry 它在一个圆内旋转一个形状
THREE.PolyhedronGeometry 这是一个椭球体;例如包括IcosahedronGeometryTetrahedronGeometry等等
THREE.ExtrudeGeometry 它从 2D 形状开始并将其拉伸到 3D 空间
THREE.ShapeGeometry 它是一个 2D 形状
THREE.TubeGeometry 它是一个空心圆柱
THREE.ParametricGeometry 这些是弯曲的管子

以拉伸为例,这是一个相对常见的操作:

var triangle = new THREE.Shape([new THREE.Vector2 (0,  50),new THREE.Vector2 (50, 50),new THREE.Vector2 (50,  0)
]);
var geometry = new THREE.ExtrudeGeometry(triangle, {bevelEnabled: false,amount: 30
});

这里的方法是创建一个由 (x, y) 坐标组成的 2D 形状 (THREE.Shape),然后沿着 z 轴拉伸它。ExtrudeGeometry 的第二个参数是一个选项映射。其中最重要的一个选项是 amount,它控制形状拉伸的距离。bevelEnabled 控制拉伸的边缘是否为圆角。你可以在下面的屏幕截图中看到结果:

自定义几何体

扩展三角形

其他自定义几何体的用例在游戏中不常见,因为通常如果你想要创建一个复杂形状,你可以在 3D 建模程序中创建一个模型,然后将其导入到 Three.js 中(这个过程在 第四章,添加细节 中有介绍)。

提示

有一个仅适用于 WebGL 的类 THREE.BufferGeometry,它的速度比 THREE.Geometry 快,但使用起来要困难得多,因为它存储的是 WebGL 缓冲区而不是 Three.js 的顶点和面。然而,Three.js 的未来发展中,默认的几何体将更类似于 THREE.BufferGeometry,这样你就不必考虑它们之间的差异。

文本

Three.js 可以使用几何体来渲染 3D 文本。要使用此功能,必须在 Three.js 库之后、自己的代码之前包含字体文件。例如,使用以下代码包含 Helvetiker 字体:

<script src="img/helvetiker_bold.typeface.js"></script>
<script src="img/helvetiker_regular.typeface.js"></script>

(在生产项目中,你应该下载你想要使用的字体并将它们本地包含。)

Three.js 在 examples/fonts 目录中包含几个字体。自定义字体必须以 typeface.js 格式(你可以在 typeface.neocracy.org/fonts.html 将 OpenType 和 TrueType 字体转换为 Typeface 格式)。使用以下代码创建文本几何体:

new THREE.TextGeometry("Text message goes here", {size: 30,height: 20, // extrude thicknessfont: "helvetiker", // font family in lower caseweight: "normal", // or e.g. boldstyle: "normal", // or e.g. italicsbevelEnabled: false
});

THREE.TextGeometry 构造函数创建一个代表 2D 文本的形状,然后像我们的三角形一样将其拉伸。你可以在下面的屏幕截图中看到结果:

文本

3D 文本

材质

材质是 THREE.Material 的实例,它定义了对象的外观。有几个常见的材质子类:

构造函数 说明
MeshBasicMaterial材质 它不受光照影响(称为 不发光 的特性),因此通常用于显示纯色或线框。相邻的两个相同颜色的不发光表面很难区分。
MeshNormalMaterial材质 此材质显示的颜色(红/绿/蓝)基于面法线向量的 x/y/z 值的大小。(一个 法线 向量垂直于一个表面。)这种材质是不发光的,因此它适用于快速区分物体的形状。
MeshDepthMaterial材料 它是一种未照明的材料,显示不同灰度的阴影,亮度取决于与摄像机的距离。在应用更逼真的纹理之前设计场景时很有用。
MeshLambertMaterial材料 面受到光照的影响,但不会发光。具体来说,光照是按顶点计算的,并在面上进行插值。如果没有灯光在场景中,它将显示为黑色。
MeshPhongMaterial材料 面受到光照的影响,并且可以发光。具体来说,光照是按每个-texel(纹理像素)计算的,因此当光源靠近相关对象时,这比 Lambert 材料更准确。如果没有灯光在场景中,它将显示为黑色。

| MeshFaceMaterial材料 | 它本质上是一个材料数组,用于将不同的材料映射到不同的表面。这种材料是独一无二的,因为它与其他材料实例化的方式不同,如下面的代码所示:

var mat1 = new THREE.MeshPhongMaterial({ color: 0x0000ff  });
var mat2 = new THREE.MeshPhongMaterial({ color: 0xff0000 });
var mat3 = new THREE.MeshPhongMaterial({ color: 0xffffff });
var materials = [mat1, mat2, mat3];
material = new THREE.MeshFaceMaterial(materials);
for (var i = 0, l = geometry.faces.length; i < l; i++) {geometry.faces[i].materialIndex = i % l;
}

在这里,我们创建了三个我们想要使用的新材料,将它们作为一个数组传递给MeshFaceMaterial,然后设置我们几何形状上的每个面,使其对应于materials数组中我们想要用于该面的材料索引。|

ShaderMaterial材料 它显示一个GLSL开放图形库着色语言)纹理。GLSL 是一种基于 C 的编程语言,由 WebGL 和 OpenGL 使用,为开发者提供了一种高级、平台无关的方式来控制图形。它非常强大,将在第四章中更多地进行讨论,添加细节

除了MeshFaceMaterial之外的所有材料构造函数都只接受一个选项映射作为它们的唯一参数。我们已经从我们的二十面体示例中遇到了三个选项:colorwireframewireframeLinewidth。此外,将transparency选项设置为true允许使用opacity选项,这是一个介于零和一之间的值,表示材料应该有多透明(零为不可见,一为不透明)。对于不使用图像的材料,可能相关的另一个选项是shading,其值为THREE.SmoothShadingTHREE.FlatShading,表示是否混合每个面的颜色,如下一张截图所示:

材料

左,THREE.MeshNormalMaterial({shading: THREE.FlatShading}); 右,THREE.MeshNormalMaterial({shading: THREE.SmoothShading});

有几个其他属性,其中最重要的也是最实用的就是map。这个属性定义了用于包裹几何形状的纹理。通常,使用这个属性看起来像以下代码片段:

var image = THREE.ImageUtils.loadTexture('image.jpg');
new THREE.MeshBasicMaterial({map: image});

注意

在加载图像时有两个需要注意的问题。首先,如果你是在本地运行你的应用程序(通过双击文件,你会看到一个 file:/// URL),出于安全原因,Chrome 默认会阻止加载图像(以防止恶意 JavaScript 访问你电脑上的本地文件)。你可以通过更改浏览器的安全设置或使用本地 HTTP 服务器运行文件来解决这个问题,具体方法请参阅 github.com/mrdoob/three.js/wiki/How-to-run-things-locally。第二个问题是,你不能在 WebGL 中渲染从另一个域加载的图像,这也是出于安全原因。你可以通过将图像与 Access-Control-Allow-Origin 标头设置为 null 来解决这个问题,具体方法请参阅 hacks.mozilla.org/2011/11/using-cors-to-load-webgl-textures-from-cross-domain-images/

ImageUtils.loadTexture() 函数用于加载图像。让我们使用一个稍微高级一点的版本来渲染 earth 图像:

THREE.ImageUtils.loadTexture('earth.jpg', undefined, function(texture) {geometry = new THREE.SphereGeometry(280, 20, 20);material = new THREE.MeshBasicMaterial({map: texture, overdraw: true});mesh = new THREE.Mesh(geometry, material);mesh.rotation.x = 30 * Math.PI / 180;scene.add(mesh);
});

loadTexture 的第二个参数目前未使用,第三个参数是一个在图像成功加载时被调用的回调函数。(还接受一个错误回调函数的第四个参数。)我们之前已经看到了除 overdraw 选项之外的所有代码,overdraw 选项消除了由于 canvas API 的限制而在网格面之间产生的小间隙。(WebGLRenderer 不需要这个属性;它可以更完美地对齐面。)你可以在下面的屏幕截图中看到结果:

材料

地球作为一个带有纹理映射的球体

在此示例中使用的图像可在 Three.js 包的 examples/textures/planets/land_ocean_ice_cloud_2048.jpg 中找到。

对于空间复杂度较高、我们无法在此处详细说明的其他材料类型,有几种其他选项。你可以在不同材料的文档中了解更多信息。例如,MeshPhongMaterial 文档(threejs.org/docs/#Reference/Materials/MeshPhongMaterial)中包含了关于产生反射表面的说明。

一个城市场景

我们已经用 Three.js API 覆盖了很多内容。让我们通过一个项目将这些关于几何和材料的知识点综合起来。

到目前为止,我们一直在我们的世界中处理单个对象。如果我们想移动它,我们就必须更改它的 position 向量。我们可以通过添加多个对象并手动定位它们来创建一个完整的场景。然而,对于包含多个对象的场景,这可能会很快变得相当繁琐。有几个替代方案:

  • 矩形布局:这种方法涉及将地图存储在某种简单格式中,如字符串或图像,其中每个字符或像素颜色代表一种对象类型

  • 程序生成: 此方法涉及使用算法半随机地定位对象

  • 编辑者: 此方法涉及使用外部工具构建场景,然后导出结果(例如,JSON 格式),并在应用程序执行时导入

矩阵格式对于简单的游戏关卡来说是最容易的,我们将在第三章 探索和交互中使用它。第五章设计和发展详细讨论了编辑器方法。现在,让我们尝试根据由Ricardo Cabello(Three.js 的原始作者)创建的示例程序生成一个城市,请参阅这里。

首先,让我们创建一个立方体和材料,我们将将其作为我们城市建筑的基础。我们将为每个新建筑复制我们的几何形状和材料,并适当地缩放几何形状:

var geo = new THREE.CubeGeometry(1, 1, 1);
geo.applyMatrix(new THREE.Matrix4().makeTranslation(0, 0.5, 0));
var material = new THREE.MeshDepthMaterial({overdraw: true});

上一代码片段的第二行将几何形状的原点(围绕该点缩放和旋转几何形状的点)移动到底部,这样当我们放大建筑时,所有建筑的楼层都将处于相同的高度。这是通过使用表示垂直平移的矩阵将每个顶点和面的 y 坐标向上移动 0.5 个单位来完成的。

注意

矩阵可以被视为具有行和列的矩形数组或表格。具有四行四列的矩阵特别适用于存储关于 3D 空间中对象的信息,因为单个 4 x 4 矩阵可以表示位置、旋转和缩放。本书中唯一提到矩阵的地方就是这里,所以如果你不理解背后的数学原理也没有关系;使用 Three.js 的一个原因就是避免手动进行线性代数。我们在这里进行的转换只是将所有顶点和面的几何形状一次性移动,而不移动其原点的一种简短方式。

接下来,我们将创建所有我们的建筑:

for (var i = 0; i < 300; i++) {var building = new THREE.Mesh(geo.clone(), material.clone());building.position.x = Math.floor(Math.random() * 200 - 100) * 4;building.position.z = Math.floor(Math.random() * 200 - 100) * 4;building.scale.x = Math.random() * 50 + 10;building.scale.y = Math.random() * building.scale.x * 8 + 8;building.scale.z = building.scale.x;scene.add(building);
}

这里唯一的新事物是clone()方法。几乎所有的 Three.js 对象都可以被克隆以创建一个副本,可以修改而不改变原始对象。我们正在利用这一点,根据我们的原始实例创建新的几何形状和材料实例。

让我们将相机放置在一个可以更好地观察的位置:

camera.position.y = 400;
camera.position.z = 400;
camera.rotation.x = -45 * Math.PI / 180;

我们已经看到旋转几次了,但重要的是要记住旋转是以弧度来衡量的。我们在这里进行的转换使相机向下倾斜 45 度。你也可以使用方便的lookAt方法。例如,camera.lookAt(new THREE.Vector3(0, 0, 0))使相机转向默认场景原点。

最后,我们还将添加一个地板:

var geo = new THREE.PlaneGeometry(2000, 2000, 20, 20);
var mat = new THREE.MeshBasicMaterial({color: 0x9db3b5, overdraw: true});
var mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -90 * Math.PI / 180;
scene.add(mesh);

PlaneGeometry() 的最后两个参数将平面分割成 20 x 20 的网格。这可以防止 Three.js 在认为所有顶点都太远而无法看到时优化掉地板。此外,平面最初是沿着 x 和 y 轴创建的,因此我们需要将其旋转 -90 度,使其在建筑下方平铺。

现在我们将所有这些放在一起:

var camera, scene, renderer;function setup() {document.body.style.backgroundColor = '#d7f0f7';setupThreeJS();setupWorld();requestAnimationFrame(function animate() {renderer.render(scene, camera);requestAnimationFrame(animate);});
}function setupThreeJS() {scene = new THREE.Scene();camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 10000);camera.position.y = 400;camera.position.z = 400;camera.rotation.x = -45 * Math.PI / 180;renderer = new THREE.CanvasRenderer();renderer.setSize(window.innerWidth, window.innerHeight);document.body.appendChild(renderer.domElement);
}function setupWorld() {// Floorvar geo = new THREE.PlaneGeometry(2000, 2000, 20, 20);var mat = new THREE.MeshBasicMaterial({color: 0x9db3b5, overdraw: true});var floor = new THREE.Mesh(geo, mat);floor.rotation.x = -90 * Math.PI / 180;scene.add(floor);// Original buildingvar geometry = new THREE.CubeGeometry(1, 1, 1);geometry.applyMatrix(new THREE.Matrix4().makeTranslation(0, 0.5, 0));var material = new THREE.MeshDepthMaterial({overdraw: true});// Cloned buildingsfor (var i = 0; i < 300; i++) {var building = new THREE.Mesh(geometry.clone(), material.clone());building.position.x = Math.floor(Math.random() * 200 - 100) * 4;building.position.z = Math.floor(Math.random() * 200 - 100) * 4;building.scale.x  = Math.random() * 50 + 10;building.scale.y  = Math.random() * building.scale.x * 8 + 8;building.scale.z  = building.scale.x;scene.add(building);}
}// Run it!
setup();

让我们看看结果,如下面的截图所示:

城市场景

程序生成的城市景观

让我们通过合并所有建筑的几何形状来优化场景。为此,我们将调整生成我们许多建筑的代码:

var cityGeometry = new THREE.Geometry();
for (var i = 0; i < 300; i++) {var building = new THREE.Mesh(geometry.clone());building.position.x = Math.floor(Math.random() * 200 - 100) * 4;building.position.z = Math.floor(Math.random() * 200 - 100) * 4;building.scale.x  = Math.random() * 50 + 10;building.scale.y  = Math.random() * building.scale.x * 8 + 8;building.scale.z  = building.scale.x;THREE.GeometryUtils.merge(cityGeometry, building);
}
var city = new THREE.Mesh(cityGeometry, material);
scene.add(city);

关键在于我们现在正在使用 GeometryUtils.merge() 将所有建筑网格合并成一个单一的 cityGeometry。这对于具有大量几何形状且不独立移动的场景是一个重要的优化,因为渲染器可以更智能地批量处理绘图调用,如果它一次知道所有顶点和面,而不是一次绘制一个网格。

光照

灯光是 THREE.Light 的实例,它影响 MeshLambertMaterialMeshPhongMaterial 表面的光照效果。大多数灯光都有颜色(以十六进制表示法指定,类似于 CSS 颜色)和强度(一个十进制值,通常在零到一之间,表示光线的亮度)。以下表格显示了不同类型的灯光:

类型 构造函数 描述
环境光
THREE.AmbientLight(color)
它以相同的方式影响场景中所有被照亮的对象。
方向性
THREE.DirectionalLight(color, intensity = 1)
对于这种类型,所有光线都是平行的,并且来自一个特定的方向,就像光源非常遥远一样。
半球
THREE.HemisphereLight(skyColor, groundColor, intensity = 1)
它模拟了来自太阳的折射光线,有点像两个相对的方向光源。
THREE.PointLight(color, intensity = 1, radius = 0)
它从一个特定的空间点发出,就像一个灯泡。它只照亮 radius 范围内的对象。
聚焦
THREE.SpotLight(color, intensity, radius = 0, coneAngle = Math.PI / 3, falloff = 10)
它从一个特定的空间点以特定的方向发出。它照亮指向其旋转方向的圆锥形对象,在 radius 范围内指数衰减。

要更新我们的程序化城市场景并添加光照,我们首先需要将建筑的材质更改为对光线做出反应:

var material = new THREE.MeshPhongMaterial({overdraw: true, color: 0xcccccc});

然后我们将光源添加到场景中:

var light = new THREE.DirectionalLight(0xf6e86d, 1);
light.position.set(1, 3, 2);
scene.add(light);

对于方向性光源,光线的方向是从 light.positionlight.target.position 的方向;这两个都是可以改变的向量,目标默认是世界原点。

让我们也把我们的渲染器改为 WebGL,因为 CanvasRenderer 不支持高级光照功能,如阴影和雾,这是我们即将添加的:

renderer = new THREE.WebGLRenderer();

现在我们场景已经添加了光照,让我们再添加一些雾气来营造一点氛围:

scene.fog = new THREE.FogExp2(0x9db3b5, 0.002);

实际上有两种雾。FoxExp2 随距离的增加而指数级变密,其参数是颜色和密度指数(一个你需要根据你世界的规模进行调整的小数)。另一种雾是 THREE.Fog,它线性变密;其参数是颜色、雾开始出现的最小距离以及物体将被雾遮挡的最大距离。例如:

scene.fog = new THREE.Fog(0x9db3b5, 0, 800);

两种雾之间的区别在静态图像中难以捕捉,但接下来的两个截图显示了指数衰减和快速线性衰减之间的对比。以下截图显示了 FogExp2 的低密度:

照明

以下截图显示了具有短衰减效果的 Fog

照明

阴影

只有 DirectionalLightPointLight 对象可以投射阴影。首先投射阴影需要我们在渲染器上启用阴影:

renderer.shadowMapEnabled = true;

如果你想,你还可以设置 renderer.shadowMapSoft = true,这将使阴影的边缘稍微平滑一些。

然后,每个将投射或接收阴影的对象必须明确设置为这样做。(阴影默认是禁用的,因为计算阴影可能会很慢。)对于我们的城市场景,我们将为地板启用阴影接收,并为建筑启用投射和接收:

floor.receiveShadow = true;
city.castShadow = true;
city.receiveShadow = true;

castShadowreceiveShadow 属性基本上就是它们听起来那样——启用投射和接收阴影。

最后,我们配置 DirectionalLight 对象以使用阴影:

light.castShadow = true;
light.shadowDarkness = 0.5;
light.shadowMapWidth = 2048;
light.shadowMapHeight = 2048;
light.position.set(500, 1500, 1000); 
light.shadowCameraFar = 2500; 
// DirectionalLight only; not necessary for PointLight
light.shadowCameraLeft = -1000;
light.shadowCameraRight = 1000;
light.shadowCameraTop = 1000;
light.shadowCameraBottom = -1000;

我们设置了光线以投射阴影,并设置了阴影的深浅。阴影的深浅通常从0(无阴影)到1(深阴影),但也可以有其他值;小于0的值会导致一种反阴影效果,即原本应该处于阴影中的物体反而被照亮,而大于1的值会使阴影显得非常黑。然后我们使用shadowMapWidthshadowMapHeight属性设置阴影的分辨率,这些属性影响阴影边缘的清晰度;值越高,阴影看起来越清晰,但计算成本也越高。接下来,我们描述将要用于投射阴影的阴影相机。实际上,当涉及到阴影时,DirectionalLightPointLight对象类似于OrthographicCameraPerspectiveCamera对象,前者使用平行投影,而后者使用透视投影。因此,为了设置我们的相机,我们将光线移动到一个足够远的位置,以便能够看到我们想要投射阴影的所有内容。然后我们使用shadowCamera属性描述锥体的形状;左、右、上、下值是锥体末端对应边的长度,而Far值是锥体末端的距离。(回想一下第一章中的你好,Three.js,锥体是包含相机所能看到的形状。)如果这很难想象,你可以这样显示锥体:

light.shadowCameraVisible = true;

结果是一个表示阴影投影的线框形状,如下一张截图所示:

阴影

阴影相机

DirectionalLight对象位于红色圆锥的顶点,黄色盒子的末端位于shadowCameraNearshadowCameraFar距离处,盒子的边缘是锥体的尺寸。对于PointLights,整个锥体是一个圆锥。

渲染器

早期,我们为了支持阴影和雾效,将渲染器从CanvasRenderer切换到了WebGLRenderer。一般来说,WebGLRenderer速度更快,功能也更全面,而CanvasRenderer功能较少,但浏览器支持范围更广。WebGLRenderer的一个特别好的功能是它支持抗锯齿,可以平滑处理锯齿边缘。我们可以通过将选项传递给渲染器构造函数来为我们的城市景观启用此功能:

renderer = new THREE.WebGLRenderer({antialias: true});

如此一来,我们的城市景观终于完成了,如下一张截图所示:

渲染器

一个完成的城市

Three.js 还有其他几种渲染器,最著名的是 CSS 和 SVG。这些可以在examples/js/renderers文件夹中找到,如果它们对应的文件被包含在您的 HTML 文档中,它们分别可用作THREE.CSS3DRendererTHREE.SVGRenderer。这些渲染器支持的特性集较小,并且使用并不广泛,因此它们没有被包含在主库中,但它们对于具有有限原始几何形状且没有光照的场景可能很有用。

在本书的剩余部分,我们将使用 WebGLRenderer,所以如果你使用的是 Internet Explorer 11 之前的版本,你应该切换到 Chrome 或 Firefox。

小贴士

如果 WebGL 不可用,你的游戏可以回退到 CanvasRenderer 或只是显示一个错误信息。这样做最简单的方式是使用 examples/js/Detector.js 中的脚本。一旦脚本被包含在你的页面中,你只需简单地检查 Detector.webgl 布尔值,以查看当前系统是否支持 WebGL。如果不支持,你可以调用 Detector.addGetWebGLMessage() 来向用户解释为什么你的游戏在他们的设备上无法运行,以及如何切换到一个支持 WebGL 的浏览器。

摘要

在本章中,我们学习了如何处理不同类型的几何形状、材质和光照。我们还了解了渲染器和场景,并完成了一个通过程序构建城市的项目。在下一章中,我们将学习用户如何与 Three.js 交互,添加一些物理效果,并构建一个基本的单人第一人称射击游戏。

第三章. 探索和交互

本章解释了用户如何与我们的游戏互动。我们还将涵盖一些物理知识,并利用所学知识创建一个基本的单人第一人称射击游戏。

键盘移动和鼠标环顾四周

为了移动我们的相机,我们将封装一些状态,所以让我们在新的 JavaScript 文件中定义一个KeyboardControls类:

function KeyboardControls(object, options) {this.object = object;options = options || {};this.domElement = options.domElement || document;this.moveSpeed = options.moveSpeed || 1;this.domElement.addEventListener('keydown', this.onKeyDown.bind(this), false);this.domElement.addEventListener('keyup', this.onKeyUp.bind(this), false);
}KeyboardControls.prototype = {update: function() {if (this.moveForward)  this.object.translateZ(-this.moveSpeed);if (this.moveBackward) this.object.translateZ( this.moveSpeed);if (this.moveLeft)     this.object.translateX(-this.moveSpeed);if (this.moveRight)    this.object.translateX( this.moveSpeed);},onKeyDown: function (event) {switch (event.keyCode) {case 38: /*up*/case 87: /*W*/ this.moveForward = true; break;case 37: /*left*/case 65: /*A*/ this.moveLeft = true; break;case 40: /*down*/case 83: /*S*/ this.moveBackward = true; break;case 39: /*right*/case 68: /*D*/ this.moveRight = true; break;}},onKeyUp: function (event) {switch(event.keyCode) {case 38: /*up*/case 87: /*W*/ this.moveForward = false; break;case 37: /*left*/case 65: /*A*/ this.moveLeft = false; break;case 40: /*down*/case 83: /*S*/ this.moveBackward = false; break;case 39: /*right*/case 68: /*D*/ this.moveRight = false; break;}}
};

在构造函数中,我们添加了对keydown事件和keyup事件的监听器,以便在按键按下时,我们可以追踪我们应该移动的方向。(在 JavaScript 中,按下的键通过数字键码来识别。)在我们的update方法中,我们只需按照指定的方向移动。这是通过检查我们在键事件期间设置的标志来实现的,这样我们就可以在每一帧中轮询键盘状态。然后,我们可以通过使用new KeyboardControls(camera)来声明控制器,并在我们的动画循环中调用controls.update(delta)来使控制器影响每一帧的相机。

如果你以前编写过事件驱动的 JavaScript,那么大部分内容应该看起来很熟悉,而且不难看出如何以不同的方式扩展以支持不同的控制机制。幸运的是,大多数应用程序的控制方式相当相似,因此 Three.js 提供了一系列默认的控制处理器,这些处理器为你处理了大部分工作。这些控制器位于examples/js/controls目录中,而不是主库中,所以如果你想要使用它们,需要确保在 HTML 文件中单独包含它们。如果你想得到稍微不同的行为,可以自由地复制并扩展现有的控制器,而不是每次都从头开始编写自己的控制器。

可用的控制器有:

构造函数 重要属性 说明
FirstPersonControls movementSpeed = 1.0 lookSpeed = 0.005 constrainVertical = false freeze = false 使用键盘移动(WASD或箭头键用于前进/后退/侧滑;使用RF上下移动)并通过跟随鼠标来环顾四周。
FlyControls movementSpeed = 1.0 rollSpeed = 0.005 按键移动(WASD),倾斜(QE),并通过锁定鼠标来环顾四周。
OculusControls freeze = false 使用 Oculus Rift 虚拟现实头盔。
OrbitControls enabled = true target = new THREE.Vector3() zoomSpeed = 1.0 minDistance = 0 maxDistance = Infinity rotateSpeed = 1.0 keyPanSpeed = 7.0 autoRotateSpeed = 2.0 使用鼠标或触摸控制旋转、平移和缩放,并保持沿正 y 轴的up方向。
PathControls duration = 10000 waypoints = [] lookSpeed = 0.005 lookVertical = true lookHorizontal = true 沿着预定义的路线移动,并通过跟随鼠标来环顾四周。
PointerLockControls 使用键盘移动(WASD或箭头键用于前进/后退/侧滑/跳跃)并通过锁定鼠标来环顾四周。需要画布处于指针锁定模式。
TrackballControls enabled = true rotateSpeed = 1.0 zoomSpeed = 1.2 panSpeed = 0.3 minDistance = 0 maxDistance = Infinity 使用鼠标或触摸控制旋转、平移、缩放和倾斜。
TransformControls size = 1 在对象周围创建一个控件,允许用户旋转、缩放和移动它。主要用于编辑器。

所有的controller构造函数都将camera作为它们的第一个参数。

让我们将FirstPersonControls控制器添加到上一章的城市示例中,并尝试飞越城市,从街道上观察城市。首先,我们需要添加 JavaScript 文件:

<script src="img/FirstPersonControls.js"></script>

然后,我们将添加一些全局变量:

var controls, clock;

接下来,我们将在setupThreeJS()中实例化controls变量和clock变量:

clock = new THREE.Clock();
controls = new THREE.FirstPersonControls(camera);
controls.movementSpeed = 100;
controls.lookSpeed = 0.1;

一个clock是一个计时器。我们将在这个案例中使用它来跟踪我们绘制每一帧之间经过的时间量。此外,请注意,我们改变了相机移动和观察的速度;否则,感觉非常迟缓。

最后,我们将更改setup()函数中的动画循环以更新我们的控制器:

requestAnimationFrame(function animate() {renderer.render(scene, camera);controls.update(clock.getDelta());requestAnimationFrame(animate);
});

更新控制器允许相机在渲染每一帧时移动。时钟的getDelta方法返回自上次调用getDelta方法以来经过的时间(以秒为单位),因此在这种情况下,它返回自上次渲染最后一帧以来经过的秒数。内部,控制器使用这个差值来确保动画随时间平滑。现在我们可以飞越我们的城市了!

你可以在下面的屏幕截图中看到城市可能从地面上看起来是什么样子:

键盘移动和鼠标观察

飞越城市

点击

在屏幕上点击以选择或与之交互是一个常见的要求,但由于需要将屏幕 2D 平面上点击的位置投影到 Three.js 的 3D 世界中,所以这比听起来要困难一些。为了做到这一点,我们从一个称为射线的想象线绘制到 3D 空间中鼠标可能的位置,并查看它是否与任何东西相交。

为了进行投影,我们首先需要一个投影仪:

projector = new THREE.Projector();

然后我们需要在画布的点击事件上注册一个监听器:

renderer.domElement.addEventListener('mousedown', function(event) {var vector = new THREE.Vector3(renderer.devicePixelRatio * (event.pageX - this.offsetLeft) / this.width * 2 - 1,-renderer.devicePixelRatio * (event.pageY - this.offsetTop) / this.height * 2 + 1,0);projector.unprojectVector(vector, camera);var raycaster = new THREE.Raycaster(camera.position,vector.sub(camera.position).normalize());var intersects = raycaster.intersectObjects(OBJECTS);if (intersects.length) {// intersects[0] describes the clicked object}
}, false);

提示

之前的代码假设你正在使用PerspectiveCamera类。如果你正在使用OrthographicCamera类,投影仪有一个实用方法返回一个适当的射线投射器,并且你不需要首先反投影向量:

var raycaster = projector.pickingRay(vector, camera);

之前的代码监听渲染器画布上的mousedown事件。然后,它创建一个新的Vector3实例,其中包含鼠标在屏幕上的坐标,相对于画布中心的百分比是画布宽度的百分比。然后,这个向量相对于相机进行反投影(从 2D 空间到 3D 空间)。

一旦我们有了表示鼠标位置的 3D 空间中的点,我们就使用Raycaster绘制一条线到它。它接收的两个参数是起始点和指向结束点的方向。我们通过减去鼠标和相机位置并规范化结果来确定方向,即通过将每个维度除以向量的长度来缩放它,以便没有维度具有大于1的值。最后,我们使用射线通过intersectObjects方法检查给定方向(即鼠标下方)上是否有对象。OBJECTS是一个要检查的对象数组(通常是网格),确保根据你的代码适当地更改它。返回一个位于鼠标后面的对象数组,并按距离排序,因此第一个结果是点击的对象。

intersects数组中的每个对象都有一个objectpointfacedistance属性。分别,这些属性的值是点击的对象(通常是Mesh)、表示空间中点击位置的Vector3实例、点击位置的Face3实例,以及从相机到点击点的距离。

也可以通过投影而不是反投影来实现反向(3D 到 2D):

var widthHalf  = 0.5 * renderer.domElement.width  / renderer.devicePixelRatio,heightHalf = 0.5 * renderer.domElement.height / renderer.devicePixelRatio;var vector = mesh.position.clone(); // or an arbitrary point
projector.projectVector(vector, camera);vector.x =  vector.x * widthHalf  + widthHalf;
vector.y = -vector.y * heightHalf + heightHalf;

在此代码运行后,vector.xvector.y将持有相对于画布左上角的指定点的水平和垂直坐标。(确保你实际上指定了想要指定的点,而不是使用mesh.position.clone(),并且你已经实例化了你的projector。)注意,如果原始 3D 点不在屏幕上,则生成的坐标可能不在画布上。

小贴士

当你的玩家疯狂点击射击敌人时,最不想看到的是整个屏幕突然变成蓝色,因为浏览器认为用户正在尝试选择某个东西。为了避免这种情况,你可以在 JavaScript 中使用document.onselectstart = function() { return false; }取消select事件,或者在 CSS 中禁用它:

* {-webkit-user-select: none;-moz-user-select: none;-ms-user-select: none;user-select: none;
}

定时

当我们开始构建更真实的示例时,你会注意到 delta 参数被传递到影响物理的函数中。这些 delta 表示自上次计算物理以来的时间量,并且它们用于平滑随时间移动。

在代码中移动对象的最简单方法是简单地改变对象的位置。例如,为了在画布上移动一个对象,你可能会在动画循环中写入obj.x += 10,以便每帧移动 10 个单位。这种方法的问题在于它依赖于帧率。换句话说,如果你的游戏运行缓慢(即每秒帧数较少),你的对象看起来也会移动缓慢;而如果你的游戏运行得快(即每秒帧数较多),你的对象看起来也会移动得快。

一种解决方案是将速度乘以渲染帧之间的时间差。例如,如果你想使你的物体每秒移动 600 个单位,你可能会写obj.x += 600 * delta。这样,你的物体将随着时间的推移移动一个恒定的距离。然而,在低帧率和高速的情况下,你的物体在每一帧都会移动很大的距离,这可能导致它做一些奇怪的事情,比如穿过墙壁。在高帧率下,计算物理可能需要的时间可能比帧之间的时间更长,这将导致你的应用程序冻结并崩溃(这被称为死亡螺旋)。此外,我们希望实现完美的可重复性。也就是说,每次我们用相同的输入运行应用程序时,我们都希望得到完全相同的输出。如果我们有可变的帧时间差,由于累积的舍入误差,我们的输出会随着程序运行时间的增长而发散,即使在正常的帧率下也是如此。

一个更好的解决方案是将物理更新时间步长与帧刷新时间步长分开。物理引擎应该接收固定大小的时差,而渲染引擎应该确定每帧应该发生多少物理更新。固定大小的时差避免了不一致的舍入误差,并确保帧之间没有巨大的跳跃。以下代码显示了如何将帧之间的时间分成离散的块以用于物理计算:

// Globals
INV_MAX_FPS = 1 / 60;
frameDelta = 0;
clock = new THREE.Clock();// In the animation loop (the requestAnimationFrame callback)…
frameDelta += clock.getDelta();
while (frameDelta >= INV_MAX_FPS) {update(INV_MAX_FPS); // calculate physicsframeDelta -= INV_MAX_FPS;
}

首先,我们声明INV_MAX_FPS,这是我们想要渲染的最大每秒帧数的倒数(在这个例子中是60)。这是我们通过update函数提供给物理引擎的时间步长,你可能需要根据你的模拟运行速度进行调整(记住,大多数显示器无法以每秒超过 60 帧的速度刷新,通常认为 30 帧以上是可接受的)。然后,我们开始跟踪我们的frameDelta,这是自上次物理更新以来的累积时间。我们的时钟将用于跟踪渲染帧之间的时间。

在动画循环中,我们首先将自上次渲染以来经过的时间加到frameDelta上,然后执行所需的固定大小的物理更新。我们可能在frameDelta中剩下一些时间,但将在下一帧中用完。

对于我们的目的,“物理更新”既包括我们世界中物体的移动,也包括玩家摄像机的移动。

第一人称射击项目

让我们编写一个真正的游戏!这个项目将比我们之前做的任何项目都要大,所以让我们首先明确我们要完成什么。我们将构建一个基于竞技场的第一人称射击游戏,具有以下功能:

  • 基于体素地图的世界

  • 一个可以在世界中四处查看、奔跑和跳跃的玩家

  • 指针锁定和全屏,以便玩家能够完全沉浸在桌面或控制台游戏中

  • 玩家应该能够射击四处游荡的敌人,而敌人应该反击

  • 当玩家被射击时,玩家和敌人的健康应该恶化,当玩家健康耗尽时,玩家应该重生。

  • 当玩家被射击时,玩家的屏幕应该闪烁红色。

  • 应该有一个带有准星和健康指示器的HUD(抬头显示)。

  • 我们不太关心光照或纹理,除了玩家必须能够看到并感知深度距离。

注意

完整的代码太长,无法在此处包含,但您可以从www.packtpub.com的账户中在线下载,或者从www.packtpub.com/support将文件通过电子邮件发送给您。本节其余部分涵盖了代码中的有趣摘录。

第一步是编写 HTML 代码。在之前的示例中,我们将所有代码都写在一个 HTML 文件中,但这是一个更大的项目,因此我们应该将代码拆分成单独的文件,并从我们的index.html文件中引用它们。我们还想在基本的 HTML 文档中添加一些用户界面元素,特别是包括一个用户必须点击才能进入游戏的start屏幕和一个hurt div,它只是一个透明的红色叠加层,当玩家被敌人子弹击中时,我们会在屏幕上短暂闪烁,作为有用的警告。

<html><head><!-- ... --><link rel="stylesheet" href="main.css" /></head><body><div id="start"><div id="instructions">Click to start</div></div><div id="hud" class="hidden"><!-- ... --><div id="hurt" class="hidden"></div></div><script src="img/three.min.js"></script><script src="img/main.js"></script></body>
</html>

当我们开始编写类时,也会将main.js文件拆分成几个不同的文件。为了简单起见,我们将所有内容都放在了同一个文件夹中。在第五章中,我们更深入地探讨了大型项目的更好组织结构。

设计地图

现在我们有一个放置代码的地方,我们需要一个有趣的世界来查看。让我们设计一个要使用的地图。例如:

var map = "XXXXXXX  \n" +"X     X  \n" +"X  S  X  \n" +"X     X  \n" +"X   S XXX\n" +"XXX     X\n" +"  XX  S X\n" +"   X    X\n" +"   XXXXXX";
map = map.split("\n");
var HORIZONTAL_UNIT = 100,VERTICAL_UNIT   = 100,ZSIZE = map.length * HORIZONTAL_UNIT,XSIZE = map[0].length * HORIZONTAL_UNIT;

我们的地图表示为一个字符串,其中X表示墙壁,S表示玩家可以进入世界的位置。我们将字符串拆分成一个数组,以便更容易访问,然后决定每个体素的大小(在本例中,由HORIZONTAL_UNITVERTICAL_UNIT变量指示的 100 * 100 * 100),并使用XSIZEZSIZE跟踪地图的整体大小。

接下来,我们需要使用我们的地图来生成 3D 世界:

for (var i = 0, rows = map.length; i < rows; i++) {for (var j = 0, cols = map[i].length; j < cols; j++) {addVoxel(map[i].charAt(j), i, j);}
}

这相当直接——遍历地图并在指定的行和列中添加一些内容。我们的addVoxel方法看起来类似于以下代码:

function addVoxel(type, row, col) {var z = (row+1) * HORIZONTAL_UNIT - ZSIZE * 0.5,x = (col+1) * HORIZONTAL_UNIT - XSIZE * 0.5;switch(type) {case ' ': break;case 'S':spawnPoints.push(new THREE.Vector3(x, 0, z));break;case 'X':var geo = new THREE.CubeGeometry(HORIZONTAL_UNIT, VERTICAL_UNIT, HORIZONTAL_UNIT);var material = new THREE.MeshPhongMaterial({color: Math.random() * 0xffffff});var mesh = new THREE.Mesh(geo, material);mesh.position.set(x, VERTICAL_UNIT*0.5, z);scene.add(mesh);break;}
}

为了看到我们的世界,我们还需要添加光照(最简单的方法是使用一个或两个类似于我们在城市项目中使用的DirectionalLights)和您可能还想添加雾来帮助深度感知。您可以手动调整摄像机的positionrotation来查看您刚刚构建的内容,或者临时添加类似于我们在城市项目中使用的FirstPersonControls。由于我们只使用我们的地图来添加墙壁,因此您还应该添加一个地板,就像我们在城市项目中做的那样,使用一个单一的大平面。

构建玩家

现在我们已经有一个世界了,让我们创建一个可以在其中移动的玩家。我们需要一个Player类来跟踪每个玩家的状态,因此让我们在一个新文件中扩展THREE.Mesh,我们将这个文件称为player.js

function Player() {THREE.Mesh.apply(this, arguments);this.rotation.order = 'YXZ';this._aggregateRotation = new THREE.Vector3();this.cameraHeight = 40;this.velocity = new THREE.Vector3();this.acceleration = new THREE.Vector3(0, -150, 0);this.ambientFriction = new THREE.Vector3(-10, 0, -10);this.moveDirection = {FORWARD: false,BACKWARD: false,LEFT: false,RIGHT: false};
}
Player.prototype = Object.create(THREE.Mesh.prototype);
Player.prototype.constructor = Player;

我们通过在Player构造函数内部调用Mesh构造函数并复制其prototype,将Player实现为THREE.Mesh类的一个子类。这意味着玩家自动具有几何形状、材质、位置、旋转和缩放,并且我们还可以实现自己的功能(例如速度和加速度)。请注意,玩家功能类似于控制器,因为它包含移动和四处张望的代码,不同之处在于输入事件处理程序绑定在类外部,以便使其可重用。

这里可能看起来有些奇怪的是改变rotation.order。旋转是通过欧拉表示法跟踪的,它包括围绕每个轴的弧度角以及轴向旋转应用的顺序。默认顺序是'XYZ',首先旋转上下(x),然后左右(y)。在这种配置下,如果玩家在垂直查看后水平查看,世界将看起来倾斜。为了可视化这一点,想象倾斜一个甜甜圈,使其远离你的那一侧向上,靠近你的那一侧向下;这就是 x 旋转,或俯仰。如果你然后从前面到左边在甜甜圈周围移动你的手指,这就是 y 旋转,或偏航。(将甜甜圈向右倾斜将是 z 旋转,或翻滚。)请注意,如果你从甜甜圈的中间向外看向你手指,你的头部相对于世界是倾斜的。因此,我们必须将欧拉顺序更改为'YXZ',以便使相机相对于世界而不是相对于自身旋转。通过这个改变,我们首先移动我们的手指,然后倾斜甜甜圈,使我们的手指向上或向下移动,而不是甜甜圈的前面,最终我们得到一个水平的头部。

要实际实现这种四处张望的功能,我们将锁定鼠标并跟踪其移动。由于 API 有些冗长,我们将使用库来简化这个过程。您可以从github.com/IceCreamYou/PointerLock.js获取PointerLock.js,以及来自布拉德·多赫蒂的 BigScreen 库,链接为github.com/bdougherty/BigScreen。一旦我们包含了这些库,启动游戏的过程将类似于以下代码,该代码在开始动画之前请求浏览器进入全屏和指针锁定模式:

document.getElementById('start').addEventListener('click', function() {if (BigScreen.enabled) {var instructions = this;BigScreen.request(document.body, function() {PL.requestPointerLock(document.body, function() {instructions.className = 'hidden';startAnimating();}, function() {stopAnimating();});}, function() {instructions.className = 'exited';stopAnimating();});}
});

指针锁定和全屏 API 只能在用户采取行动(点击或按键)时启用,作为安全预防措施,以防止攻击者劫持您的屏幕,因此在这种情况下我们正在等待点击。一旦我们进入全屏,我们可以监听mousemove事件来旋转玩家:

document.addEventListener('mousemove', function(event) {player.rotate(event.movementY, event.movementX, 0);
}, false);

提示

在这里,event.movementX属性和event.movementY属性通过PointerLock.js库在所有浏览器中进行了归一化。

rotate()方法只是简单地改变玩家的_aggregateRotation向量。在这里,我们假设玩家已经被实例化,如下面的代码所示:

player = new Player();
player.add(camera);
scene.add(player);

是的,我们刚刚将camera添加到了player中。结果是,任何是THREE.Object3D的子对象都可以添加其他对象。这些子对象可以通过父对象的children数组访问,并且它们将与父对象一起分组,以便运动、旋转和缩放可以组合。换句话说,如果一个子对象的局部位置是(0, 0, 5),而父对象的位置是(0, 0, 10),那么子对象在世界的位置将是(0, 0, 15)。旋转和缩放的工作方式类似。在这种情况下,我们使用这种组合来使我们的相机跟随玩家移动。

小贴士

因为子对象的位置、旋转和缩放都是相对于父对象的位置、旋转和缩放而言的,所以你可以通过将相机放置在正 z 轴上(可能在 y 轴上稍微高一点)并给player对象一个几何形状和材质(记住,Player是从Mesh继承的,所以你可以使用new Player(geometry, material)来实例化一个玩家)来创建一个第三人称相机。

玩家运动

我们很快就会完成代码,以便四处查看,但由于它与玩家的移动密切相关,让我们先来解决这个问题。

物理运动

正确实现运动是一个复杂的话题。虽然简单地给一个对象的位置加上一个恒定速度在某些模拟中可能有效,但更高级的游戏将希望有加速度(例如,用于重力)以及可能的其他力。线性力的最直接方法是跟踪加速度和速度向量并将它们相加:

// When the mesh is instantiated
mesh.velocity = new THREE.Vector3(0, 0, 0);
mesh.acceleration = new THREE.Vector3(0, 0, 0);// Called in the animation loop
function update(delta) {// Apply accelerationmesh.velocity.add(mesh.acceleration().clone().multiplyScalar(delta));// Apply velocitymesh.position.add(mesh.velocity.clone().multiplyScalar(delta));
}

这被称为欧拉积分(发音为“oiler”,而不是“yew-ler”)。一个简单的修改就给了我们中点积分,这可以在准确性上带来合理的改进。我们所需做的只是在使用速度之前和之后将加速度应用一半:

  var halfAccel = mesh.acceleration.clone().multiplyScalar(delta * 0.5);// Apply half acceleration (first half of midpoint formula)mesh.velocity.add(halfAccel);// Apply thrustmesh.position.add(mesh.velocity.clone().multiplyScalar(delta));// Apply half acceleration (second half of midpoint formula)mesh.velocity.add(halfAccel);

要理解它是如何工作的,请考虑以下图表:

物理运动

欧拉法与中点积分

我们积分公式的目标是尽可能接近真实位置。在图中,垂直跳跃发生在我们的时间步长,即物理更新计算的地方。中点曲线只是将欧拉曲线平移,使得中点位置和真实位置之间的面积相互抵消。当应用加速度、急速和非线性力时,会引入更多的误差,但就我们的目的(以及我们的空间)而言,中点公式是在简单性和准确性之间的一种合理的折衷。

四阶龙格-库塔方法(也称为RK4)是另一种常用的方法,用于计算随时间变化的运动。RK4 在帧之间预测几个中间状态,从而为下一帧的状态提供更精确的最终近似。增加准确性的代价是增加了复杂性和降低了速度。由于其复杂性,我们在这里不会介绍它,但通常如果你需要像这样复杂的东西,你可能会希望将物理处理委托给下一节中提到的碰撞处理库之一。

更新玩家的移动和旋转

让我们监听移动键,以便我们知道何时移动玩家:

document.addEventListener('keydown', function(event) {switch (event.keyCode) {case 38: // upcase 87: // wplayer.moveDirection.FORWARD = true;break;case 37: // leftcase 65: // aplayer.moveDirection.LEFT = true;break;case 40: // downcase 83: // splayer.moveDirection.BACKWARD = true;break;case 39: // rightcase 68: // dplayer.moveDirection.RIGHT = true;break;case 32: // spaceplayer.jump();break;}
}, false);

我们将在每一帧检查这些标志,以确定应用多少推力。我们还需要一个keyup监听器,它几乎与keydown监听器相同,不同之处在于当相关键被释放时,它应该将我们的方向设置回false

现在,终于可以实施玩家的update方法了:

Player.prototype.update = (function() {var halfAccel = new THREE.Vector3();var scaledVelocity = new THREE.Vector3();return function(delta) {var r = this._aggregateRotation.multiplyScalar(delta).add(this.rotation);r.x = Math.max(Math.PI * -0.5, Math.min(Math.PI * 0.5, r.x));this.rotation.x = 0;if (this.moveDirection.FORWARD) this.velocity.z -= Player.SPEED;if (this.moveDirection.LEFT) this.velocity.x -= Player.SPEED;if (this.moveDirection.BACKWARD) this.velocity.z += Player.SPEED;if (this.moveDirection.RIGHT) this.velocity.x += Player.SPEED;halfAccel.copy(this.acceleration).multiplyScalar(delta * 0.5);this.velocity.add(halfAccel);var squaredVelocity = this.velocity.x*this.velocity.x + this.velocity.z*this.velocity.z;if (squaredVelocity > Player.SPEED*Player.SPEED) {var scalar = Player.SPEED / Math.sqrt(squaredVelocity);this.velocity.x *= scalar;this.velocity.z *= scalar;}scaledVelocity.copy(this.velocity).multiplyScalar(delta);this.translateX(scaledVelocity.x);this.translateZ(scaledVelocity.z);this.position.y += scaledVelocity.y;this.velocity.add(halfAccel);this.velocity.add(scaledVelocity.multiply(this.ambientFriction));this.rotation.set(r.x, r.y, r.z);this._aggregateRotation.set(0, 0, 0);};
})();

这里有很多事情在进行中。首先要注意的是,我们的方法定义立即调用一个匿名函数,该函数返回我们的实际方法定义。我们这样做是为了创建一些辅助对象以提高效率。大多数 Three.js 数学运算都是在原地进行的(而不是返回每个操作的结果的新对象),这意味着为了使用现有的数学对象(如acceleration向量)进行计算,我们要么需要克隆它们,要么将值复制到一个helper对象中,这样我们就可以在不产生副作用的情况下对其进行操作。克隆会产生过多的垃圾回收,这意味着如果浏览器必须处理我们快速创建然后丢弃的所有对象,它将会滞后。相反,我们定义了halfAccel向量,例如,在闭包中(这样它就不会污染全局命名空间),并使用它进行向量数学运算。这种模式在 Three.js 库本身中经常被使用。

几乎在update方法中的其他所有操作都是加法和乘法。为了观察周围环境,我们汇总鼠标在每一帧之间的移动距离,然后在玩家更新时添加相应的旋转量。此外,加速度和速度部分应该看起来很熟悉——它与我们刚刚在物理移动部分提到的相同的中点策略。不过,我们还需要对一些问题保持敏感。首先,我们限制r.x以约束玩家向上和向下看的距离,这样他们就不会混淆上下方向。其次,我们希望前进的概念相对于世界而不是相机的方向,这样我们就可以向上看并向前走,而不会在所看方向上飞到空中。为此,我们在将速度添加到位置之前重置俯仰角(使玩家向前看而不是向上或向下看)。最后,我们添加摩擦力,这允许玩家在给定方向移动后减速并停止。在你的实际游戏中,你可能希望根据玩家是否在空中使用不同级别的摩擦力。

玩家碰撞

检测碰撞有几种不同的方法:

  • 体素:如第二章中所述,构建世界,设计世界的一种常见方法是用字符串或图像来表示可重复的构建块,例如乐高积木。使用这种方法时,检查角色与世界之间的碰撞最快的方法是简单地检查角色的坐标是否在地图指定的用于构建块的区域之内。这避免了比较 3D 形状的复杂性。

  • 光线:就像我们之前使用Raycaster类来检测点击的对象一样,我们也可以使用它来检测多个对象之间的碰撞,使用intersectObjects()方法。为此,我们可以从需要检查碰撞的对象(如玩家)向多个方向发射光线;例如,我们可以从玩家的位置向玩家的每个顶点发射光线。如果交点距离小于玩家位置到顶点的距离,则表示发生了碰撞。这种方法有几个问题。首先,对于大量顶点或动态对象来说,它效率不高。其次,如果对象不在光线的方向上,它们可能会逃避免检测。最后,光线使用对象形状的近似来检查交点,因此不规则形状的对象可能会被错误地选中。然而,这是最简单的一般性方法,可以在不使用额外库或了解世界布局的情况下工作。

  • 交点:我们可以手动比较对象的几何形状和位置,以查看它们是否重叠。由于检测复杂 3D 形状之间的碰撞在数学上是困难的,因此大多数游戏使用简化的近似值而不是实际几何形状来简化计算。即便如此,除非我们使用非常简单的形状(如盒子或球体,它们并不能很好地近似我们的对象),否则 3D 碰撞检测算法既复杂又慢。如果没有一些复杂的优化,如使用名为八叉树的数据结构来确保只有附近的对象需要检查碰撞,这也会很耗费计算资源。如果你想要尝试实现自己的碰撞检测,Three.js 在examples/js文件夹中包含了一个八叉树的实现。

  • :幸运的是,我们不必手动进行复杂的碰撞检测,因为有几个库可以为我们处理这些复杂性。它们还处理碰撞响应和相关物理。主要的竞争者包括:

    • Ammo.js是一个从 C++编译到 JavaScript 的大型但完整的库。它可在github.com/kripken/ammo.js/找到。

    • Cannon.js是一个从头开始用 JavaScript 编写的较小库,部分灵感来自 Three.js 的概念。它可在github.com/schteppe/cannon.js找到。

    • Physi.js是 Ammo 或 Cannon 与 Three.js 之间的桥梁,它还在单独的线程中运行物理模拟以避免阻塞渲染。它可在github.com/chandlerprall/Physijs找到。

对于我们的射击游戏,我们将使用体素碰撞和一些手动交点。不幸的是,所有物理库都很大,所以我们没有足够的空间在这里介绍它们的 API。然而,Cannon.js 和 Physi.js 在其项目页面上提供了专门用于与 Three.js 一起使用的示例。

体素碰撞

如果我们现在尝试在世界中行走,我们只会穿过地板。让我们创建一个函数来检查玩家与体素世界之间的碰撞:

function checkPlayerCollision(player) {player.collideFloor(floor.position.y);var cell = mapCellFromPosition(player.position);switch (cell.char) {case ' ':case 'S':break;case 'X':moveOutside(cell, player);break;}
}

我们的collideFloor方法确保玩家保持在地板的 y 位置之上。然后,mapCellFromPosition方法从玩家的位置查找地图单元格,以确定玩家是否在墙壁或开放空间中。如果玩家正在与墙壁碰撞,moveOutside()方法通过将玩家移向最近的单元格来将玩家移出墙壁。从位置查找单元格的过程与我们最初放置每个体素时使用的正好相反:

var XOFFSET = (map.length+1) * 0.5 * HORIZONTAL_UNIT,ZOFFSET = (map[0].length+1) * 0.5 * HORIZONTAL_UNIT,col = Math.floor((position.x+XOFFSET) / HORIZONTAL_UNIT) - 1,row = Math.floor((position.z+ZOFFSET) / HORIZONTAL_UNIT) - 1,char = map[mapRow].charAt(mapCol);

机器人

现在我们已经让玩家工作起来,是时候添加敌人机器人了。敌人可以是像用户一样的Player,所以除了初始化它们之外,我们主要需要添加的是自主行为。我们没有足够的空间在这里深入讨论人工智能策略,所以我们只是偶尔随机设置每个机器人的moveDirection标志:

bot.rotation.y = Math.random() * Math.PI * 2;
bot.moveDirection.FORWARD = Math.random() < 0.8;

子弹

最后,让我们添加射击功能,以便我们可以粉碎那些敌人!首先,我们将在bullet.js中创建一个新的Bullet类,类似于我们为Player类所做的那样。子弹只是具有direction向量和speed标量的网格,因此它们的update方法可以相当简单:

Bullet.prototype.update = (function() {var scaledDirection = new THREE.Vector3();return function(delta) {scaledDirection.copy(this.direction).multiplyScalar(this.speed*delta);this.position.add(scaledDirection);};
})();

当子弹被射出时,我们将设置子弹的方向。子弹可以朝向摄像机的方向射出,或者从敌方机器人向玩家射出。为了获取相关方向,我们的shoot函数将类似于以下代码:

var shoot = (function() {var negativeZ = new THREE.Vector3(0, 0, -1);return function(from, to) {bullet = new Bullet();bullet.position.copy(from.position);if (to) {bullet.direction = to.position.clone().sub(from.position).normalize();}else {bullet.direction = negativeZ.clone().applyEuler(from.rotation);}bullets.push(bullet);scene.add(bullet);};
})();

我们通过减去他们的位置来获取一个玩家到另一个玩家的方向。如果用户正在射击,那么我们不一定瞄准任何东西,所以我们只需要摄像机正在看的方向。我们从玩家的旋转中检索这个方向。

更新游戏循环

将所有这些整合在一起,我们应该得到一个类似以下的功能,它执行我们游戏的所有行为:

function update(delta) {player.update(delta);checkPlayerCollision(player);for (var i = bullets.length - 1; i >= 0; i--) {bullets[i].update(delta);checkBulletCollision(bullets[i], i);}for (var j = 0; j < enemies.length; j++) {var enemy = enemies[j];enemy.update(delta);checkPlayerCollision(enemy);if (enemy.health <= 0) {spawn(enemy);}shoot(enemy, player);move(enemy);}if (player.health <= 0) {spawn(player);}
}

此函数计算所有物理(包括移动和碰撞检测),触发自主行为,如机器人向目标射击,并实现游戏逻辑(如玩家健康过低时死亡)。它在动画循环的每一帧中被调用。delta 参数是物理时间步长,因此它应该始终与本章时间部分中讨论的相同值相同。

写了这么多代码!不过,我们得到了一个很棒的基于竞技场的第一人称射击游戏,你可以将其上线并发送给所有你的朋友。你可以从以下图像中看到所有这些工作的样子:

更新游戏循环

游戏完成后的截图

摘要

在本章中,我们学习了如何实现用户交互和游戏物理。我们还构建了一个完整的单人第一人称射击游戏。在下一章中,我们将通过导入模型、粒子系统、声音和后期处理效果来丰富我们的世界。

第四章。添加细节

本章解释了如何管理外部资产,如 3D 模型,以及如何使用粒子系统、声音和图形效果为你的世界添加细节。它还将详细说明我们在第三章中构建的竞技场第一人称射击游戏,将其转变为夺旗游戏。

设置 CTF

为了有一个合适的夺旗游戏,我们首先需要拥有队伍。有一些事情需要与特定的队伍相关联:

  • 旗帜(以及旗帜颜色)

  • 玩家(以及玩家的皮肤)

  • 生成点

  • 子弹(如果你想要避免同队伤害)

  • 可能的地图装饰/材质

将这些元素中的每一个与一个队伍关联的最简单方法是为它们添加一个具有简单值(如RB)的属性来表示红队或蓝队(或任何其他队伍名称)。一个更高级的方法可能是创建一个Team类,该类包含属于该队伍的所有内容的引用,因为这可以提供优化,例如限制需要执行的碰撞检查的数量。但是,如果你这样做,确保在从世界中移除某些东西(如子弹)时从Team容器中删除所有适当的引用,以避免内存泄漏。

接下来,我们需要修改我们的地图以添加红队和蓝队的旗帜,我们将分别用RB表示:

var map = "XXXXXXX   \n" +"X  S  X   \n" +"X  R  X   \n" +"X     XX  \n" +"X      XXX\n" +"XXX      X\n" +"  XX     X\n" +"   X  B  X\n" +"   X  S  X\n" +"   XXXXXXX";

现在我们需要将这些旗帜实际添加到世界中。然而,旗帜不是简单的几何原形形状,因此我们希望导入一个更复杂的网格。

资产管理

原始几何形状非常适合测试,但任何严肃的游戏今天都可能大量使用在 Blender、Maya 或 3ds Max 等专业程序中创建的 3D 模型。这些模型需要导入到 Three.js 场景中,并转换为具有几何和材质的THREE.Mesh对象。幸运的是,Three.js 为各种文件格式提供了名为loaders的导入器。

导入器

对于我们的旗帜,我们将使用一个简单的 Collada 格式的网格。(Collada 是一种基于 XML 的格式,用于存储 3D 网格和动画数据,文件以.dae结尾。)你可以从 Packt Publishing 网站下载我们的旗帜网格。ColladaLoader不包括在主 Three.js 库中,但可以从examples/js/loaders/ColladaLoader.js复制,然后将其包含在你的 HTML 中,如下所示:

<script src="img/ColladaLoader.js"></script>

然后,模型可以像这样加载:

var loader = new THREE.ColladaLoader();
loader.load('flag.dae', function(result) {scene.add(result.scene);
});

通常需要调整和重新定位导入的模型,因此你可能会想在将网格添加到世界之前设置result.scene.scaleresult.scene.position。你可以在下一张截图看到加载的模型:

导入器

蓝队的旗帜

注意

默认情况下,在本地file:/// URL 上导入网格将不会工作。这是因为当 JavaScript 请求文件时,浏览器的默认安全设置拒绝返回本地系统文件。为了绕过这个限制,你可以运行一个本地 HTTP 服务器或更改浏览器安全设置,具体方法请参阅github.com/mrdoob/three.js/wiki/How-to-run-things-locally

还有许多其他用于其他文件格式的模型加载器,包括 CTM、OBJ、MTL、PLY、STL、UTF8、VRML 和 VTK。这些位于examples/js/loaders文件夹中。几乎所有的加载器都有一个load方法,就像之前提到的ColladaLoader方法一样,该方法在加载完成后调用一个函数。然而,加载器没有标准化的格式,其中一些工作方式略有不同。特别是,传递给回调函数的参数取决于文件类型。你应该检查你想要使用的加载器的examples文件夹中的演示,以确保你正确处理返回的结果。

在我们的例子中,我们从ColladaLoader获取一组子网格(在result.scene中),因为 Collada 文件可以包含多个网格。我们需要修改标志的材质,以确保每个标志反映其队伍的颜色:

result.scene.children[1].material = new THREE.MeshLambertMaterial({color: type === 'R' ? 0xee1100 : 0x0066ee,side: THREE.DoubleSide,
});

回想一下,在上一章中我们设置相机跟随玩家时,我们将相机添加到了player对象中。每当以这种方式将对象分组时,它们可以通过父对象的children数组访问。在这种情况下,我们使用该数组来改变旗帜布料部分的材质,使其变为蓝色或红色,具体取决于它属于哪个队伍。

除了标准 3D 模型文件格式的加载器之外,库中还包含了一些 Three.js 特定的加载器。特别是,THREE.JSONLoader被设计用来加载单个网格,而THREE.SceneLoader可以加载整个场景(包括灯光、相机和其他 Three.js 实体)。

注意

除了 3D 模型之外,还有一些内置的加载器用于其他资产。例如,我们已经看到了THREE.TextureLoaderTHREE.ImageUtils.loadTexture背后的工作。你也可以直接加载网格的各个部分,包括几何形状、图像和材质。其他对象,如灯光、相机,甚至任意资源也可以加载。然而,这些加载器通常在库的底层调用,而不是直接由开发者调用,因为通常加载整个模型或场景比加载单个部分更有意义。因此,我们在这里不会介绍这些加载器,但如果你想要了解更多,可以在src/loaders文件夹中找到它们。

ColladaLoader 类似,JSONLoader 使用一个带有回调的 load 方法。然而,回调接收一个 THREE.Geometry 对象作为其第一个参数。并非所有 3D 模型都有关联的材料,但如果对象有材料,它们将以数组的形式作为第二个参数传递给回调:

var loader = new THREE.JSONLoader();
loader.load('model.js', function(geometry, materials) {var material = materials && materials.length ?new THREE.MeshFaceMaterial(materials) :new THREE.MeshBasicMaterial({ color: 0x000000 });var mesh = new THREE.Mesh(geometry, material);scene.add(mesh);
});

如第二章《构建世界》中所述,MeshFaceMaterial 是一个容器,它将多个材料映射到网格的不同面上。

SceneLoader 与其他加载器略有不同,因为它可以使用其他加载器来处理场景的特定部分:

var loader = new THREE.SceneLoader();
loader.addGeometryHandler('ctm', THREE.CTMLoader);
loader.addHierarchyHandler('dae', THREE.ColladaLoader);
loader.load('scene.js', function(result) {
scene.add(result.scene);
});

如果场景包含外部模型,SceneLoader 将尝试使用适当的处理程序导入它。使用 addGeometryHandler 方法添加仅支持单个网格的文件格式的加载器,并使用 addHierarchyHandler 添加支持多网格场景的文件格式的加载器(DAE、OBJ 和 UTF8)。在这个例子中,CTM 和 DAE 文件将被正确加载。

导出到 Three.js

Three.js 项目包括用于 3ds Max、Maya 和 Blender 3D 建模程序的扩展,以便更容易地将模型导出为 Three.js JSON 格式。这些扩展有一些限制;例如,一些修改器如平滑组不支持。有两种常见的替代方案可以避免这些问题。第一种是将模型导出为 DAE 等格式,并使用相应的 Three.js 导入器。另一种方法是导出模型为 OBJ 格式,然后在utils/converters文件夹中运行 Python 转换器脚本,将模型转换为 Three.js JSON 格式。选择文件格式主要是权衡文件大小(文件检索所需时间)和初始化(文件解析所需时间)。你可能需要为性能敏感的项目测试不同的格式。

小贴士

Python 转换器是为 Python 2.x 编写的,可能不适用于 3.x。

导入模型时出现最常见的问题是由于没有导出所有必需的属性。在你的建模程序的导出对话框中,如果可以选择,请确保勾选这些属性的复选框:

  • 顶点

  • 面部

  • 法线

  • 皮肤/材质/纹理贴图/纹理坐标/UV/颜色

  • 翻转 YZ

  • 形态动画(如果适用)

  • 所有网格(如果适用)

你的建模软件可能没有所有这些选项,你可能还需要检查其他复选框。

为了安全起见,你可能还想确保你导出的模型是一个顶级对象,而不是与其他事物组合在一起,模型没有被平移或旋转,缩放设置为1,并且你已经删除了模型的历史记录。

从 Three.js 导出

Three.js 在examples/js/exporters文件夹中提供了几个导出器,允许以各种格式保存场景或对象,包括 OBJ、STL 和 JSON。与加载器一样,几乎任何 Three.js 实体都可以导出,但最常见的方法是导出一个完整的网格或场景。SceneExporter工具在这里是最常见的工具,使用它相当直接:

var exporter = new THREE.SceneExporter();
var output = JSON.stringify(exporter.parse(scene), null, "\t");

然后,output值可以被保存到一个SceneLoader可以稍后读取的 JSON 文件中。需要注意的一个主要问题是自定义属性不会被导出。这包括添加到对象实例中的非标准属性、Three.js 类子类提供的属性、不继承自 Three.js 类的自定义类,以及不属于scene的任何内容。如果你需要任何这些内容被导出,你可能最好编写一个自定义导出器和导入器,可能从 Three.js 提供的其中一个开始。

管理加载器

现在,当我们的 CTF 地图初始化时,我们要求一个加载器获取旗帜模型。这没问题,因为模型相当小,我们只需要加载一个模型。然而,如果我们有很多模型或者它们很大,我们可能会注意到它们在加载完成后突然出现在地图上,即使我们已经开始玩游戏。为了解决这个问题,较大的项目应该在玩家开始玩游戏之前预加载资源。为此,只需要禁止进入地图,直到最后一个模型的回调函数执行完毕。不幸的是,如果我们有很多模型并且必须逐个加载它们,可能会很难跟踪剩余的模型数量。当模型加载时,如果没有任何事情发生,用户也可能失去兴趣。

通常,这是通过使用SceneLoader一次性加载所有模型来解决的。SceneLoader对象有一个callbackProgress属性,它包含一个在场景中的每个对象完成加载后调用的函数。该回调函数接受两个参数,progressresultprogress对象有四个数值属性,可以用来显示进度条:totalModelstotalTexturesloadedModelsloadedTexturesresult对象包含到目前为止已加载的所有实体,它也是当所有加载完成后传递给加载器load回调函数的onLoad参数的值。

对于进度条,可以考虑如下 HTML 代码,其中外层 div 有一个定义的宽度:

<div id="bar"><div id="progress"></div></div>

在这种情况下,你可以在callbackProgress处理程序中包含如下代码:

var total = progress.totalModels + progress.totalTextures,loaded = progress.loadedModels + progress.loadedTextures,progressBar = document.getElementById('progress');
progressBar.style.width = Math.round(100 * loaded / total) + '%';

如果你不能使用SceneLoader或者出于其他原因不想使用它,你必须手动将你的加载器连接起来。然而,Three.js 未来将开始使用加载管理器。加载管理器是与加载器一起工作的对象,用于跟踪多个资源何时完成加载。截至 Three.js 版本 r61,加载管理器 API 尚不稳定,并且许多加载器中尚未实现。

网格动画

使用动画模型与使用普通模型没有太大区别。除了在 Three.js 中手动更改网格几何形状的位置外,还有两种类型的动画需要考虑。

小贴士

如果你只需要在不同值之间平滑地过渡属性——例如,为了使门旋转以动画打开,你可以使用Tween.js 库来这样做,而不是直接动画化网格。Jerome Etienne有一个关于如何进行这种动画的很好的教程,可以在learningthreejs.com/blog/2011/08/17/tweenjs-for-smooth-animation/找到。

形变动画

形变动画将动画数据存储为一系列位置。例如,如果你有一个具有缩小动画的立方体,你的模型可以保留立方体顶点的完整大小和缩小大小的位置。然后动画将包括在每个渲染或关键帧期间在这两种状态之间进行插值。表示每种状态的数据可以包含顶点目标或面法线。

要使用形变动画,最简单的方法是使用THREE.MorphAnimMesh类,它是普通网格的子类。在下面的示例中,如果模型使用法线,则应仅包含突出显示的行:

var loader = new THREE.JSONLoader();
loader.load('model.js', function(geometry) {var material = new THREE.MeshLambertMaterial({color: 0x000000,morphTargets: true,morphNormals: true,});if (geometry.morphColors && geometry.morphColors.length) {var colorMap = geometry.morphColors[0];for (var i = 0; i < colorMap.colors.length; i++) {geometry.faces[i].color = colorMap.colors[i];}material.vertexColors = THREE.FaceColors;}geometry.computeMorphNormals();var mesh = new THREE.MorphAnimMesh(geometry, material);mesh.duration = 5000; // in millisecondsscene.add(mesh);morphs.push(mesh);
});

我们首先将材质设置为知道网格将使用morphTargets属性进行动画,并且可选地使用morphNormal属性。接下来,我们检查颜色是否会在动画期间改变,如果是这样,则将网格面设置为它们的初始颜色(如果你知道你的模型没有morphColors,你可以省略该块)。然后计算法线(如果我们有它们)并创建我们的MorphAnimMesh动画。我们设置整个动画的duration值,最后将网格存储在全局morphs数组中,这样我们就可以在物理循环中更新它:

for (var i = 0; i < morphs.length; i++) {morphs[i].updateAnimation(delta);
}

在内部,updateAnimation方法只是更改网格应在其动画中插值的位置集。默认情况下,动画将立即开始并无限循环。要停止动画,只需停止调用updateAnimation

骨骼动画

骨骼动画通过使顶点群跟随“骨骼”的运动来一起移动网格中的顶点。这通常更容易设计,因为艺术家只需要移动几个骨骼,而不是可能成千上万的顶点。这也通常更节省内存,原因相同。

要使用形变动画,使用THREE.SkinnedMesh类,它是普通网格的子类:

var loader = new THREE.JSONLoader();
loader.load('model.js', function(geometry, materials) {for (var i = 0; i < materials.length; i++) {materials[i].skinning = true;}var material = new THREE.MeshFaceMaterial(materials);THREE.AnimationHandler.add(geometry.animation);var mesh = new THREE.SkinnedMesh(geometry, material, false);scene.add(mesh);var animation = new THREE.Animation(mesh, geometry.animation.name);animation.interpolationType = THREE.AnimationHandler.LINEAR; // or CATMULLROM for cubic splines (ease-in-out)animation.play();
});

在这个示例中,我们使用的模型已经具有材质,因此与形态动画示例不同,我们必须更改现有的材质而不是创建一个新的材质。对于骨骼动画,我们必须启用皮肤,这指的是材质如何随着网格的移动而包裹网格。我们使用THREE.AnimationHandler实用程序来跟踪我们在当前动画中的位置,并使用THREE.SkinnedMesh实用程序来正确处理我们的模型骨骼。然后我们使用网格创建一个新的THREE.Animation并播放它。动画的interpolationType决定了网格在状态之间如何过渡。如果您想使用立方样条插值(慢-快-慢),请使用THREE.AnimationHandler.CATMULLROM而不是LINEAR插值。

我们还需要在物理循环中更新动画:

THREE.AnimationHandler.update(delta);

同时使用骨骼和形态动画是可能的。在这种情况下,最好的方法是将动画视为骨骼动画,并手动更新网格的morphTargetInfluences数组,如 Three.js 项目中的examples/webgl_animation_skinning_morph.html中所示。

粒子系统

现在我们已经放置了旗帜,并且我们已经学会了如何管理我们需要的资源来装饰我们的世界,让我们添加一些额外的视觉效果。我们将首先查看的第一种效果是粒子系统。

粒子是始终面向摄像机的平面,通常成组在一起形成一个系统,以产生一些效果,如火焰或蒸汽。它们对于创建像这样五彩斑斓的心形等出色视觉效果至关重要:

粒子系统

来自 examples/webgl_particles_shapes.html 的示例中的粒子,形状由 zz85 设计

捕获旗帜

当玩家捕获旗帜时,我们希望启动一个庆祝性的烟花式展示,所以如果您还没有这样做,请继续添加捕获旗帜的机制。核心逻辑应该在一个函数中,我们将在物理循环中的每个玩家上调用此函数:

function checkHasFlag(unit) {var otherFlag = unit.team === TEAMS.R ? TEAMS.B.flag : TEAMS.R.flag;if (unit.hasFlag) {var flag = unit.team === TEAMS.R ? TEAMS.R.flag : TEAMS.B.flag;if (flag.mesh.visible && isPlayerInCell(flag.row, flag.col)) {otherFlag.mesh.traverse(function(node) {node.visible = true;});unit.hasFlag = false;}}else if (otherFlag.mesh.visible && isPlayerInCell(otherFlag.row, otherFlag.col)) {otherFlag.mesh.traverse(function(node) {node.visible = false;});unit.hasFlag = true;}
}

如果玩家拥有对手的旗帜,我们会检查他们是否站在自己的旗帜上,以便他们可以得分;如果玩家没有旗帜,我们会检查他们是否站在另一面旗帜上,以便他们可以偷取它。当旗帜被偷走时,它会被标记为不可见,当它被归还时,它会被标记为可见。(此代码不包括在先前的示例中,但当旗帜携带者死亡时,旗帜也需要被归还。)

小贴士

对象的visible属性是一个布尔值,用于控制它们是否被渲染。在 WebGL 渲染器中,设置此属性不会影响子对象,尽管它会影响其他渲染器中的子对象。对于多部分网格,这一点很重要,因为它们通常以层次结构导入。要设置对象及其所有子对象的可见性,您可以使用traverse方法,该方法为层次结构中的每个对象调用回调函数:

object.traverse(function(node) {node.visible = false;
});

粒子和精灵

CanvasRendererWebGLRenderer 使用不同的对象来表示单个粒子。当使用画布时,使用 THREE.Particle

var material = new THREE.ParticleBasicMaterial({color: 0x660000,map: null, // or an image texture
});
var particle = new THREE.Particle(material);

如您所见,粒子基本上是由颜色或图像组成的。同样,在使用 WebGL 时,使用 THREE.Sprite

var material = new THREE.SpriteMaterial({color: 0x660000,map: null, // or an image textureopacity: 1.0,blending: THREE.AdditiveBlending,
});
var sprite = new THREE.Sprite(material);

Sprite 与粒子基本上相同,只是它们还支持不同的混合模式。支持的混合模式在以下屏幕截图中显示:

粒子和 Sprite

不同的混合模式,如 examples/webgl_materials_blending.html 中的示例所示

在这两种情况下,您可以使用 positionscale 向量,就像我们之前使用网格一样,只是 scale.z 没有作用。

粒子系统

粒子系统是一种同时创建和管理大量粒子的方法。它们使用几何形状将粒子放置在每个顶点上。这有一个好处,那就是您可以使用我们之前已经看到的内置工具来操纵几何形状。例如,您可以使用带有导入的动画几何形状的粒子系统。然而,它们也有一些限制。首先,创建动态效果可能很困难,因为您必须手动编码它们,例如通过在每一帧中更新每个单独粒子的速度。其次,您不能添加和删除粒子(尽管您可以将其不透明度设置为零),因此您必须预先分配可能需要的粒子数量。第三,每个粒子系统只能使用一种材质,因此给定系统中的所有粒子都将具有相同的图像、大小和旋转(尽管您可以独立地更改它们的颜色)。

为了我们 CTF 游戏的目的,当创建旗帜时,我们将创建一个庆祝的粒子系统:

var geometry = new THREE.IcosahedronGeometry(200, 2);
var mat = new THREE.ParticleBasicMaterial({color: type === 'R' ? TEAMS.R.color : TEAMS.B.color,size: 10,
});
var system = new THREE.ParticleSystem(geometry, mat);
system.sortParticles = true;
system.position.set(x, VERTICAL_UNIT * 0.5, z);
scene.add(system);

这将在初始化的旗帜周围创建出大致球形的微小粒子。sortParticles 属性指示是否应按深度对粒子进行排序,以便靠近摄像机的粒子出现在远离摄像机的粒子之前。有时,当粒子移动和重叠时,排序粒子可能会产生奇怪的爆裂效果,因此您可能想要测试并看看哪种效果更适合您。此外,当您有数万个粒子时启用排序可能会很昂贵,尽管在我们的示例中只有几百个粒子应该没问题。

为了完成效果,我们实际上想通过将 system.visible = false 使系统不可见,然后在稍后捕获旗帜时临时使其可见。此外,如果粒子四处移动,我们的粒子将更有趣。我们可以通过更改其旋转向量来旋转整个粒子系统:

system.rotation.y += delta * 1.5;

您可以在以下屏幕截图中看到结果:

粒子系统

庆祝粒子效果

我们也可以直接移动几何形状的顶点。为此,我们首先需要在创建粒子系统之前将 geometry.dynamic = true 设置为 true,然后每次更改顶点位置时都设置 geometry.verticesNeedUpdate = true

对于我们的游戏,我们不需要这样做,但也可以通过更改geometry.colors数组来改变单个粒子的颜色。你可以为每个顶点(在相同的索引处)填充一个颜色,并且该颜色将与粒子的材质颜色或图像混合。

你可能想要对粒子做一些事情,可能会变得相当复杂。例如,为了模拟瀑布的喷雾,你可能需要使用应用了一些物理学的粒子。为了简化类似的高级用例,已经出现了两个库。其中一个,称为Sparks,实际上是包含在 Three.js 的examples/js文件夹中的。它也在线上可用,网址为github.com/zz85/sparks.js,由zz85编写。由Luke MoodyLee Stemkoski编写的一个较新的库可在github.com/squarefeet/ShaderParticleEngine找到,尽管相对未经测试,但其 API 更简单,且在重量上更轻。

声音

虽然 Three.js 是一个图形库,但在examples/js/AudioObject.js中有一个实验性的THREE.AudioObject类,它使用 Web Audio API 来支持 3D 音效。该对象继承自Object3D,因此可以附加到其他对象并放置在世界中。它旨在使用空间精确的 3D 音效。主要的限制是,由于浏览器不兼容,该类仅从 Three.js 版本 r61 开始与 Chrome 兼容。

小贴士

就像外部模型一样,音频通过 AJAX 加载,因此默认情况下本地文件 URL 无法工作。

话虽如此,让我们继续尝试在捕获旗帜时添加一些欢呼声。首先,当初始化我们的旗帜时,我们将创建我们的AudioObject实例:

var cheering = new THREE.AudioObject('cheering.ogg', 0, 1, false);
scene.add(cheering);

此代码创建了一个对象来播放cheering.ogg文件,音量为0,播放速率为1,且不循环。我们最初将音量设置为零,因为AudioObject会立即播放声音,而我们只想在捕获旗帜时播放。为此,让我们在捕获旗帜时触发声音播放:

THREE.AudioObject.call(cheering, 'cheering.ogg', 1, 1, false);

AudioObject不提供再次播放声音的方法,因此我们必须调用构造函数来强制它这样做。这次,我们将音量设置为1。人群疯狂了!

如果你将最终参数设置为true而不是false,你也可以使用这个来播放循环声音或甚至音乐。

渲染器效果和后处理

有时,改变整个显示效果可以给游戏或区域增添很多个性。Three.js 支持两种主要类型的效果:渲染器和后处理。

渲染器效果可以在examples/js/effects中找到。它们改变渲染器输出的内容,通常是通过以不同的设置多次渲染场景来实现的。例如,Anaglyph 效果会产生熟悉的红蓝阴影,与 3D 眼镜配合使用可以使场景从屏幕中突出出来,这是通过为左眼、右眼和一次组合渲染场景来实现的。设置起来很容易:

effect = new THREE.AnaglyphEffect(renderer);
effect.setSize(renderer.domElement.width, renderer.domElement.height);

然后只需调用effect.render(scene, camera)而不是renderer.render(scene, camera)。除了 ASCII 效果外,所有其他渲染器效果的工作方式都相同,ASCII 效果需要添加一个单独的画布,以便将其渲染为文本字符。

后处理效果通过在场景上应用 GLSL 着色器来实现。examples/jsexamples/js/postprocessingexamples/js/shaders文件夹中有许多可以使用的着色器。其中大多数只是有趣,但有一些在游戏中很有用。例如,DOF景深)效果会模糊远处的物体,并将近处的物体聚焦。

examples/js/postprocessing中的EffectComposer使应用后处理变得更容易,并允许使用多个效果。例如,要使用EdgeShader,首先在您的 HTML 中添加必要的文件:

<script src="img/EdgeShader.js"></script>
<script src="img/CopyShader.js"></script>
<script src="img/ShaderPass.js"></script>
<script src="img/RenderPass.js"></script>
<script src="img/MaskPass.js"></script>
<script src="img/EffectComposer.js"></script>

然后设置效果:

composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
var effect = new THREE.ShaderPass(THREE.EdgeShader);
effect.uniforms['aspect'].value.x = renderer.domElement.width;
effect.uniforms['aspect'].value.y = renderer.domElement.height;
composer.addPass(effect);
effect = new THREE.ShaderPass(THREE.CopyShader);
effect.renderToScreen = true;
composer.addPass(effect);

此代码在两个后处理渲染过程中请求应用边缘和复制着色器。边缘效果需要了解画布大小,并且效果将在复制着色器应用后渲染。最后一步是将我们的renderer.render(scene, camera)调用替换为composer.render(),您可以在下一张截图看到,这是一个相当戏剧性的结果:

渲染器效果和后处理

我们带有边缘着色器后处理器的游戏

如前所述,着色器是用 GLSL 而不是 JavaScript 编写的,并且它们可以变得相当复杂。因此,我们在这里不会讨论如何编写它们。然而,您可以在glsl.heroku.com/浏览并 fork 其他人编写的着色器。Three.js 的作者Mr.doob也编写了一个着色器编辑器,网址为www.mrdoob.com/projects/glsl_sandbox/Thibaut Despoulain也编写了一个,网址为shdr.bkcore.com/。请注意,着色器可以用来显示几乎任何东西,但大多数着色器作为后处理来说并没有意义。

摘要

在本章中,我们学习了如何管理 3D 模型和动画。我们还探讨了粒子系统、声音和视觉效果。此外,我们利用所学知识将我们的第一人称射击游戏从第三章,探索和交互,转变为夺旗游戏。在下一章中,我们将讨论游戏设计概念、工作流程过程、性能考虑和网络。

第五章:设计与开发

虽然其他章节已经关注了 Three.js API 及其如何用于构建游戏,但本章讨论了如何利用 Three.js 和 Web 平台制作优秀的游戏。我们将利用到目前为止所学的内容作为基础,来探索游戏设计概念和开发流程,研究性能考虑因素,并介绍基于 JavaScript 的游戏网络。

网络游戏设计

基于 WebGL 构建的游戏,其质量可以与或超过游戏机,这是可能的,而且这样做是一个值得追求的目标。此外,为网络构建游戏也提供了一个利用桌面游戏和游戏机无法实现的功能的机会,尽管也存在一些缺点。

例如,你可以围绕将游戏数据放在 URL 中构建机制。除了仅仅表示保存/加载点之外,URL 还可以编码拾取物、位置、随机种子或其他信息。加入分享功能后,用户就可以通过电子邮件或推文将链接发送给朋友,并立即进入游戏中的相同位置。与游戏机游戏不同,网络游戏可以利用病毒式动态、浏览器的普遍性和低门槛来吸引更多用户并引入新的游戏玩法。你可以想象需要一定数量玩家才能完成的协作解谜游戏——这个概念对于昂贵的游戏机游戏来说可能并不可靠。

同时,购买昂贵游戏机的玩家可能更有可能付出努力来克服初始的学习曲线。除非用户预先为你的游戏付费,否则重要的是要意识到用户可以像他们到达时一样轻易地离开你的游戏。游戏始终是关于平衡难度和参与度的;这是一个相同的公式,但考虑从点击你的着陆页到第一次品尝甜蜜满足感之间的时间间隔比以往任何时候都更重要。

基于 Web 的游戏还受益于强大的 API 和集成传统。当然,游戏机游戏也可以使用 API,但根据定义,基于 Web 的游戏通常可以依赖玩家拥有互联网连接,因此你可以想象游戏元素,如 Google Earth 的图像、基于 Foursquare 数据的位置名称和提示,以及积极使用社交网络的 AI 角色。特别是,你可以轻松地将支付处理集成到你的游戏中,可能甚至是在收银台或 ATM 机上叠加,并合理预期许多用户将能够通过键盘更容易地输入他们的信用卡信息,而不是使用游戏手柄。这为非传统支付方式打开了大门,这些支付方式不需要预先收费购买游戏,更类似于移动游戏通常从应用内销售中赚钱的方式。

此外,笔记本电脑用户对摄像头和麦克风的访问需求正在迅速增长,Chrome 和 Firefox 现在通过 WebRTC API 支持这些外围设备。这项技术的潜在用途远不止简单的聊天。环境声音可以被检测并用于调整游戏音乐的节奏。借助一些机器视觉或 Leap Motion 设备,用户可以通过挥手直接与游戏互动,而不是操纵鼠标。想象一下一款《过山车大亨》游戏,你真的可以拿起游客并将他们扔到公园的另一边!机器视觉还有许多其他酷炫的应用。约翰·卡马克(多款标志性游戏如《毁灭战士》和《雷神之锤》的首席程序员)最近建议在用户眨眼时进行垃圾回收。麻省理工学院的研究表明,摄像头视频可以用来准确识别游客的心率,这可以让游戏调整它们的节奏以匹配(或补偿)用户的兴奋程度([people.csail.mit.edu/mrub/vidmag/](http://people.csail.mit.edu/mrub/vidmag/))。而且,手部追踪技术已经被用于 3D 建模、游戏开发,甚至火箭设计。

其他外部设备,如 Oculus Rift 增强现实头盔,也可以得到支持,以便更深入地集成到你的环境中。(Three.js 实际上在examples/js/controls文件夹中包含了一个 Oculus Rift 的控制器)。例如,手机可以用作网络游戏的控制器,正如在http://cykod.com/blog/post/2011-08-using-nodejs-and-your-phone-to-control-a-browser-game和blog.artlogic.com/2013/06/21/phone-to-browser-html5-gaming-using-node-js-and-socket-io/中描述的那样。一些浏览器也提供了对传统 USB 游戏控制器的实验性支持;一个帮助实现这一点的库可以在www.gamepadjs.com/找到。手机和平板电脑甚至可以用作额外的屏幕——可能是用于小地图、物品清单或后视镜。

话虽如此,鉴于网络普遍可用,考虑一下可能访问你的游戏的设备和它们可能面临的限制。不同的屏幕尺寸和分辨率并不新鲜,但触摸控制尤其可能给 3D 游戏带来挑战。尽管如此,将传统的网络开发技术应用于游戏可以产生创造性的解决方案。例如,采用优雅降级/渐进增强的方法,移动用户可以收到观众视角或其他简化的游戏版本。或者,当没有键盘和鼠标时,你可以提供屏幕上的控制,可能使用 HTML5 虚拟游戏控制器库(github.com/austinhallock/html5-virtual-game-controller)。

最后一点需要思考:由 Three.js 驱动的游戏可以与现有网站无缝集成。例如,大多数游戏需要菜单,而在 HTML 中创建它们比在 3D 中要容易得多。不要觉得你的整个应用程序必须仅显示在画布上。另一方面,如果你想发挥创意,你实际上可以在 Three.js 环境中嵌入 HTML。你可以在learningthreejs.com/blog/2013/04/30/closing-the-gap-between-html-and-webgl/jensarps.de/2013/07/02/html-elements-in-webgl-recursion/了解更多关于如何做到这一点。

性能

在某些方面,浏览器中 3D 游戏性能的考虑与控制台和桌面游戏非常相似。最大的区别是所有资源必须(至少最初)流式传输到客户端,而不是从磁盘读取。对于具有数十亿字节资产的复杂 3D 游戏,克服低带宽客户端的这一限制可能是一个严重的挑战。

小贴士

如传奇程序员唐纳德·克努特所写:

"过早优化是万恶之源。"

本节讨论了最佳实践和建议,以从你的游戏中获得出色的性能,但在投入大量精力之前,你应该测量和测试你的应用程序,以查看瓶颈在哪里,以及这些努力是否值得。

带宽/网络限制

为了应对带宽限制,你应该做的第一件事是应用传统的优化,这些优化是网络开发者多年来一直在使用的:使用gzip压缩服务器发送的内容,合并并压缩 JavaScript 以最小化浏览器需要向服务器发出的请求数量,优化你的图像,启用 Keep-Alive 头,从有限数量的域中提供资产,并使用头信息利用浏览器缓存,以及其他技术。

小贴士

优化网站通常是一个特别详细的话题,但本节主要专注于解释针对游戏的优化。如果您想了解更多关于 Web 性能优化WPO)的信息,可以从 Google 和 Yahoo! 的这些规则开始:

  • developers.google.com/speed/docs/best-practices/rules_intro

  • developer.yahoo.com/performance/rules.html

然而,复杂的游戏无法依赖于浏览器缓存来处理用户的回访,因为浏览器对缓存资源在整个网站中可以消耗的最大内存量有限。当用户浏览其他网站时,您的游戏资源可能会被推离缓存,而且缓存的大小可能对于所有资源来说也太小了。因此,下一个需要寻找优化的地方是在您的游戏内部进行缓存,以最小化需要发出的服务器请求数量。这可以通过三种方式实现。首先,您可以将资源存储在其他缓存中。IndexedDB API (developer.mozilla.org/en-US/docs/IndexedDB) 支持存储文件,而 Web Storage API (developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Storage#localStorage) 支持存储字符串(包括 JSON,因此您可以存储导出的 Three.js 对象)。Chrome 还支持 FileSystem API (www.html5rocks.com/en/tutorials/file/filesystem/),它可以管理一个沙盒化的本地文件系统。其次,您可以通过在客户端生成一些资源来减少您需要的资源总数。例如,如果您需要不同颜色的相似纹理,动态地在客户端修改这些纹理可能比请求服务器上的多个图像更有意义。或者,如果您可以用一个方程组来描述网格应该如何动画,您可能考虑在客户端手动动画它而不是发送动画数据。第三,您确保您的代码结构允许重用资源而不是反复请求它们。例如,如果两个网格使用相同的纹理,您应该尝试只加载一次纹理,而不是两次。

最后,您可以使用比标准基于文本的文件具有更高压缩率的二进制格式来导入网格,以减少服务器需要发送到客户端的资产大小。为此,您应该将您的网格导出为 Wavefront OBJ / MTL 文件,然后使用 utils/converters/obj/convert_obj_three.py 中的转换脚本生成一个 THREE.BinaryLoader 可以导入的文件。(运行脚本的说明在文件顶部。)

除了减少从服务器检索的资源总大小外,你还可以在用户不需要看到它的情况下尽可能多地加载数据。例如,如果玩家从菜单进入你的游戏,你可以在玩家浏览菜单时开始加载,而不是等到他们点击开始游戏。你也可以等到玩家进入游戏后再加载场景中最初不可见的部分,以便让玩家尽可能快地开始游戏。例如,基于线性任务的游戏可以等到玩家到达某些检查点时再加载地图的部分。只是确保你有一个后备计划,以防资源加载缓慢且玩家过早地到达未加载的区域。你可能想要有一个门,直到资源加载完毕才打开。你也可以在玩家处于过渡位置时暂时暂停游戏。

细节级别

类似地,当用户开始玩游戏时,你可以加载低多边形网格和低分辨率纹理,并在游戏过程中用更高细节的资产替换它们,无论是当加载较大资产时还是当用户足够接近它们以看到改进的细节时。后者技术称为细节级别LOD),Three.js 使用 THREE.LOD 对象内置了对它的支持。例如,我们可以修改我们在第一章中构建的旋转形状示例,根据我们离球体有多近来改变球体的细节。首先我们需要改变我们如何将网格添加到场景中:

geometry = [[new THREE.IcosahedronGeometry(200, 4), 50],[new THREE.IcosahedronGeometry(200, 3), 300],[new THREE.IcosahedronGeometry(200, 2), 1000],[new THREE.IcosahedronGeometry(200, 1), 2000],[new THREE.IcosahedronGeometry(200, 0), 8000],
];
material = new THREE.MeshNormalMaterial();lod = new THREE.LOD();
for (var i = 0; i < geometry.length; i++) {var mesh = new THREE.Mesh(geometry[i][0], material);lod.addLevel(mesh, geometry[i][1]);
}
scene.add(lod);

LOD 对象存储不同复杂性的对象以及应使用更高细节版本的距离。为了使网格在相机靠近或远离时改变细节,我们将在动画循环中更新 LOD 对象:

scene.traverse(function(object) {if (object instanceof THREE.LOD) {object.position.z = 2500 * Math.cos(Date.now() / 1000);object.update(camera);}
});

我们在这里添加了一点点移动,这样我们就可以通过设置 camera.position.z = 3000 来更好地看到细节的变化。现在你应该能够看到细节的动态变化,如下面的截图所示:

细节级别

随着相机靠近而细节增加的球体

渲染优化

Three.js 还内置了对其他与细节相关的优化支持,以便使处理更快。剔除,即从渲染中排除隐藏对象的过程,是一个常见的例子。Three.js 基于边界球体进行视锥剔除,这意味着它会避免花费宝贵的计算时间来计算屏幕外对象的视觉信息。它还默认进行背面剔除,隐藏网格面的背面。然而,它不进行遮挡剔除,这意味着它不知道不要渲染一个位于相机前方但被更靠近相机的另一个对象遮挡的对象。这些优化的含义是,大型网格通常应该分成几个较小的网格,如果只有大型网格的一部分在屏幕上,就可以减少计算量,并且默认情况下,缩短可视距离不会带来任何好处。这种简单的改变可能对于顶部视角游戏就足够了,在这种游戏中,很少有对象被其他对象遮挡。其他游戏,如第一人称射击游戏,其中建筑物或地形可能会阻挡长距离的视野,可能需要以其他方式补偿。例如,如果你有非常大的或详细的场景,你可能需要手动进行遮挡剔除。游戏引擎通常使用一种称为深度测试的技术来完成这项工作,但对于封闭布局(如建筑物的内部)来说,一个更简单的方法是根据视距创建包含世界不同区域的不可见立方体,然后在玩家足够接近时切换这些区域内部网格的可见性。

我们已经在第二章中讨论了合并几何体的优势,构建世界,但你还可以通过将静态几何体转换为 BufferGeometry 来获得额外的性能提升。BufferGeometry 通常比标准 Geometry 渲染更快,因为它使用的数据结构更接近将被传递给 GPU 的数据结构,而不是人类容易理解的数据结构。因此,它更难操作,但如果你知道你的几何体不会改变,它工作得很好。使用 BufferGeometry 的最简单方法是使用 examples/js/BufferGeometryUtils.js 中的实用工具从现有的 Geometry 转换。

THREE.BufferGeometryUtils.fromGeometry(geometry);

你可以使用结果与网格相同的方式使用正常几何体。

另一种强大的优化是改变画布的分辨率。假设 renderercamera 是全局变量,你可以使用此函数来做到这一点:

var resize = (function() {var canvas = renderer.domElement;canvas.style.width = canvas.width + 'px';canvas.style.height = canvas.height + 'px';var originalWidth = canvas.width;var originalHeight = canvas.height;return function(scale) {canvas.width = Math.round(originalWidth*scale);canvas.height = Math.round(originalHeight*scale);camera.aspect = canvas.width / canvas.height;camera.updateProjectionMatrix();renderer.setSize(canvas.width, canvas.height);}
})();

你可以通过调用resize(0.5)这样的函数来使用此功能,这将允许渲染器只绘制0.5*0.5 = 25%的像素,即使画布在屏幕上占据相同的空间。(scale参数始终相对于画布的原始大小。)这是因为画布基本上只是图像。就像你可以在 CSS 中通过不改变其实际大小来调整图像的大小一样,你也可以调整画布的大小。在我们的resize函数中,我们首先通过改变其widthheight属性来减小画布的实际大小,然后使用 CSS 的宽度和高度样式将其放回原来的大小。结果是,画布占据的屏幕空间与原来相同,但每个实际像素显示得更大。这显著减少了渲染场景所需的计算量,尽管场景会变得模糊。

小贴士

改变画布的分辨率会影响你需要计算用户点击位置的方式。你应该跟踪画布的当前缩放比例,并相应地调整第三章中点击方法中的屏幕空间向量,探索和交互

  var vector = new THREE.Vector3(scale *  renderer.devicePixelRatio * (event.pageX - this.offsetLeft) / this.width * 2 - 1,scale * -renderer.devicePixelRatio * (event.pageY - this.offsetTop) / this.height * 2 + 1,0.5);

为了速度而牺牲视觉细节的技术在结合帧率测试时特别有用。如果在某个测试期间,帧率低于某个特定阈值的时间超过给定百分比,你可能想要降低游戏的细节级别。(在实施此方法之前,你应该找出你的瓶颈是什么。如果你的帧率低是因为你的物理循环运行时间过长,降低视觉细节可能帮助不大。)

小贴士

如果物理是你的瓶颈,你可以按照第三章中讨论的,以低于渲染的帧率运行你的物理,第三章:探索和交互。你也可能想要考虑使用 Web Worker API(developer.mozilla.org/en-US/docs/Web/Guide/Performance/Using_web_workers)来并行执行 JavaScript 代码。这可以允许在不阻塞渲染的情况下计算运动和碰撞。在第三章中引入的 Physi.js 库,探索和交互,会自动执行此操作。

电池续航和 GPU 内存

尽管带宽/网络速度和处理器时间通常是影响 Three.js 游戏性能的最主要因素,但电池寿命和内存限制也可能发挥作用。对于硬核游戏,你可能可以假设用户已经插上电源,但更休闲的游戏应该意识到更多的处理通常意味着更多的电池消耗。在内存方面,问题更多是关于存储空间,而不是图形卡有限的嵌入式内存,它可以进行快速计算。你可以通过使用压缩纹理来限制你消耗的 GPU 板载内存量。通常,图像(如 JPG 和 PNG)在发送到 GPU 之前会被解压缩,但压缩纹理使用一种特殊的格式,允许 GPU 以压缩状态将它们保存在嵌入式内存中。由于压缩只对 GPU 有影响,实际上并不节省网络带宽。Three.js 支持 DDS 格式的压缩纹理。你可以这样将 DDS 纹理导入到 Three.js 中:

var texture = THREE.ImageUtils.loadCompressedTexture(imagePath);

生成的texture值可以像处理普通图像一样处理;例如,你可以将其用作材质的map属性的值,Three.js 将自动知道如何处理它。

小贴士

要创建 DDS 图像,你可以使用 Gimp 的插件(code.google.com/p/gimp-dds/)或 Photoshop(developer.nvidia.com/nvidia-texture-tools-adobe-photoshop)。

性能测量工具

最后,还有一些非常有用的工具可以用来测量 JavaScript 的性能。方便的是,Three.js 的原始作者编写了一个名为 Stats.js 的库(github.com/mrdoob/stats.js),用于跟踪帧率,这是游戏中最关键的性能统计指标。对于全面的跟踪,Google 的 Web Tracing Framework(google.github.io/tracing-framework/index.html)难以匹敌,它甚至提供了一个跟踪 WebGL 游戏的示例。你还可以使用来自Jerome Etienne的 RenderStats 库(github.com/jeromeetienne/threex.rendererstats)轻松获取一些关于屏幕几何形状的统计数据。

对于暴力调试,你可能还想要尝试 console-extras 库,它使得在不输出数千条消息的情况下记录主游戏循环中发生的事情变得更加容易(github.com/unconed/console-extras.js)。

网络和多人游戏

游戏网络很困难,因为网络的目标是在多个设备上保持游戏状态同步,但网络延迟阻止了设备以足够的速度进行通信以保持该状态偶尔不一致。此外,浮点舍入误差会在同一组输入(这是第三章中讨论的计时和移动技术发挥作用的地方,探索和交互),因为精度的小差异可能导致随时间产生巨大的差异)。因此,网络代码成为协调差异的过程。

根据游戏的需求,基本上有两种不同的网络方法。实时战略(RTS)和回合制游戏通常使用一种称为锁步的方法,这是一种对等模型,其中每台参与比赛的个人电脑都会将其命令发送给比赛中的其他所有电脑。这种模型的主要优势是只需要发送少量数据(玩家的命令)到网络上,因此当游戏状态非常大时(例如,当地图上有成千上万的单位时)非常有用。然而,在锁步模式下运行游戏依赖于所有玩家拥有游戏状态的相同副本,这在理论上是伟大的想法,但出于几个原因难以维持。首先,尽管 JavaScript 规范声明浮点计算应该是确定的,但在实践中,不同实现之间可能存在细微的差异,这可能导致客户端无法同步。

第二,所有客户端都将看到游戏以最延迟客户端的速度运行,因为必须收集每个玩家的命令才能前进以确保同步。因此,必须采取额外预防措施,以防止客户端通过假装具有更高的延迟并在做出决定之前等待其他电脑的命令来作弊。当一台机器需要特别长的时间来返回命令时,延迟问题也会造成麻烦。在这种情况下,游戏可能不得不放弃那个玩家。由于使用锁步的原因是整个游戏状态太大,无法在网络中传输同时保持同步,因此玩家在游戏开始后可能无法加入(或重新加入)。

游戏网络的其他方法是客户端-服务器预测模型,通常工作方式如下:

  1. 客户端触发一些输入(例如按下键或移动鼠标)以改变游戏状态。

  2. 客户端输入被发送到服务器。

  3. 可选地,服务器将输入转发给其他客户端。

  4. 服务器处理从所有玩家接收到的输入,进行协调,并在特定时间生成游戏状态的新的、权威的描述。

  5. 如果服务器转发了其他客户端的输入,客户端会接收到这些输入,并通过预测服务器认为状态应该是什么来继续更新本地游戏状态。

  6. 服务器定期向每个客户端发送权威游戏状态的最新完整描述。

  7. 客户端调整其状态以与服务器官方状态同步。

与锁步相比,这里的主要区别是客户端可以独立推进游戏,整个游戏状态可以从服务器发送,以确保每个玩家看到的内容与其他人看到的内容非常接近。这对于像 FPS 或像大型多人在线游戏这样的多人游戏来说是一个更好的模型,因为玩家通常经历的延迟效应不那么剧烈。

为了减少玩家可能感受到的延迟,我们设计客户端-服务器通信为异步的,因为从服务器获取新的游戏状态可能因为网络延迟而花费很长时间。由于我们试图在等待服务器的同时本地运行游戏,当我们最终从服务器获得权威更新时,我们需要调整客户端。调整客户端可能很棘手。首先,当我们从服务器获得响应时,它发送给我们的状态将是过去的。为了处理这个问题,我们需要记录自上次我们获得官方服务器更新以来的所有玩家输入,将游戏回滚到新接收到的权威游戏状态,然后在此基础上重新播放任何更近期的输入。结果将是我们对服务器认为当前时间游戏状态应该是什么的最新猜测,这可能会与我们实际上向玩家展示的内容略有不同。我们本可以将当前游戏状态瞬间切换到理想的游戏状态,但这会使游戏看起来抖动,因为事物可能会突然传送。相反,如果状态之间的差异足够小,客户端应该在当前状态和预期的理想状态之间进行插值。如果我们偏离得太远,我们可以切换回服务器状态,但否则我们会在几帧内滞后,以确保平滑性。切换通常发生在复杂的物理交互或玩家碰撞时。

理想情况下,我们只想将玩家的输入发送到客户端,因为它们比完整游戏状态小(因此占用的网络数据包更少)。这可能是有数千名玩家的大型多人在线游戏的唯一合理处理方式。然而,这可能会因为浮点数舍入误差而随着时间的推移产生漂移,所以这可能不足以成为像第一人称射击游戏这样紧张动作游戏的唯一解决方案。作为折衷方案,输入可以频繁发送,而完整游戏状态只定期发送;然后客户端可以使用输入来预测游戏应该如何进展。

当然,并非所有物理现象都是由用户输入驱动的。如果你的游戏具有受自然驱动的游戏玩法物理特性,例如风或雪崩,你可能需要服务器在没有客户端预测的情况下模拟物理现象,而客户端只需处理一些延迟。另一方面,你可以在客户端完全模拟一些物理现象。例如,天空中的云彩在各个客户端上是否完全处于相同的位置并不重要,因为它们通常只是装饰性的。

服务器跟踪的游戏状态通常至少包括所有可移动角色的位置和速度、状态的唯一标识符以及时间戳。服务器不需要向每个客户端发送完整的场景导出,因为这会非常昂贵。然而,服务器确实需要模拟整个场景,以便准确更新游戏状态。

注意

想了解更多关于锁步的信息,请查看www.altdevblogaday.com/2011/07/09/synchronous-rts-engines-and-a-tale-of-desyncs/www.altdevblogaday.com/2011/07/24/synchronous-rts-engines-2-sync-harder/

想了解更多关于客户端-服务器预测的信息,请查看gafferongames.com/networking-for-game-programmers/what-every-programmer-needs-to-know-about-game-networking/

想了解关于在权威状态和客户端状态之间进行插值的信息,请参阅www.gamedev.net/page/resources/_/technical/multiplayer-and-network-programming/defeating-lag-with-cubic-splines-r914

Rob Hawkes 有一个关于使用 HTML5 进行多人游戏开发的精彩 Google 技术演讲,其中包含许多关于克服常见陷阱的建议。您可以在www.youtube.com/watch?v=zj1qTrpuXJ8观看。

技术

Web Sockets API (developer.mozilla.org/en-US/docs/WebSockets) 是在 JavaScript 中与游戏服务器保持快速连接的最实用方式,使用 Web Sockets 的最简单方法是使用服务器上的 node.js (nodejs.org/) 和 socket.io 库 (socket.io/)。Node.js 允许 JavaScript 成为一等的服务器端语言,因此您可以一次编写游戏代码,并减少对服务器端和客户端模拟之间差异的担忧。在单一语言中编写服务器端和客户端代码在心理上也要容易得多。

注意

Web sockets 是我们目前能做的最好的选择,因为 JavaScript 在访问互联网方面没有桌面和控制台游戏那么多的控制权,这是出于安全考虑。Web sockets 实际上相当不错,但它们基于 TCP,这是一种常见的互联网访问方式,确保了可靠性但偶尔会造成延迟。许多使用客户端-服务器预测的桌面和控制台游戏使用 UDP 来访问互联网,它服务于与 TCP 相同的目的,但为了最小化延迟而牺牲了数据完整性保证。

Voxel.js (voxeljs.com/) 是一个使用 Three.js 的网络游戏框架的好例子。如果你正在构建类似 Minecraft 的游戏,这是一个很好的起点。有关使用 Socket.io 和 Node.js 在 JavaScript 中编写游戏网络代码的更多信息及代码示例,可以在 buildnewgames.com/real-time-multiplayer/ 找到。它以二维游戏为例,但所有内容都同样适用于三维游戏。

反作弊

在多人游戏中阻止作弊者是一个一般性的难题,在 JavaScript 中尤其困难,原因有三。一是很难检测客户端输入是否自动化或他们的显示是否非法更改;二是 JavaScript 代码相对难以混淆和验证,而不会带来显著的性能损失;三是作弊程序可以直接且容易地覆盖你的客户端代码。因此,反作弊努力通常集中在尽可能将逻辑从客户端移动到服务器,检测客户端活动的异常模式,最小化作弊的好处,并可能创建足够的烦人障碍,让一些有抱负的作弊者放弃。常见的方法包括:

  • 只允许客户端向服务器发送白名单内的输入,而不是任意值;这允许受信任的计算机(服务器)执行重要计算,并避免让作弊者发出非法请求,例如addPoints(1000000)

  • 跟踪用户玩游戏的时间长度;如果用户连续玩 48 小时,那么值得调查

  • 跟踪用户操作之间经过的时间;如果用户每隔 10 分钟(可疑地准确)或 16 毫秒(可疑地快)点击屏幕的同一部分,他们可能正在自动化他们的行为

  • 报告“快速瞄准”,这是第一人称射击游戏中的一种行为,即当没有任何障碍物时,立即转向射击目标,即使目标不在屏幕上,也永远不会错过

  • 使调试更困难,例如通过禁用控制台日志记录(console = {})并将整个客户端代码包裹在一个闭包中,以防止任何全局变量轻易被作弊者的脚本访问

  • 让用户更容易报告滥用行为

这当然不是一份详尽的清单,而且完全阻止作弊者是非常困难的,但这些建议是一个合理的起点。

小贴士

通常认为在单玩家模式中防止客户端作弊是不好的做法,除非是其他玩家可以看到的物品,例如高分。

开发流程

不论你是作为爱好构建游戏的个人还是大型游戏出版商的开发者,你都可以从遵循从 Web JavaScript 开发和在其他平台上的游戏开发中吸取的一些最佳实践中受益。你还可以在不偏离你喜欢的游戏开发流程的情况下构建基于 Three.js 的游戏。

JavaScript 最佳实践

在前面的章节中,我们并没有非常关注我们代码的高级结构。我们编写了一些作为单个 HTML 文件的示例,并将 FPS 和 CTF 项目拆分为单独的文件,但对于精炼的游戏,我们应该更加小心,尤其是在与团队合作时。一般的编码课程适用:

  • 根据文件类型/目的将资源放在文件夹中。例如,在较高层次上,你可能会有cssjsimagesmodelssounds这样的文件夹。在js文件夹中,根据目的组织 JavaScript 文件;将库、源代码和生成代码分开。

  • 避免在类构造函数中放置直接处理用户输入事件监听器的代码,因为这会使它们更难重用和扩展。

  • 使用单独的文件来配置/常量,你可以调整这些文件来改变游戏的感觉。

  • 检测特性而不是浏览器,因为不同的浏览器版本支持不同的特性,某些特性可以在浏览器设置中打开或关闭。

  • 避免在动画循环中使用setTimeoutsetInterval作为计时器,因为有很多单独的计时器可能会导致性能问题。相反,检查动画循环中经过的时间,例如使用Date.now()THREE.Clock

    小贴士

    如果你的游戏可以暂停,确保你不会包含暂停期间的时间流逝。

  • 虽然将整个应用程序用 JavaScript 编写可能感觉很自然,但尽量避免在 JavaScript 中创建新的 DOM。这样做很慢,而且 HTML 和 CSS 存在是有原因的。(此外,不要害怕使用 HTML 和 CSS;有时这比编写自定义着色器要简单得多。)

  • 使用样式指南。你选择哪个并不特别重要,但保持一致的样式有助于避免那些在其他情况下容易受到 JavaScript 动态类型和表达性语法影响的愚蠢错误。

  • 对于习惯于经典面向对象编程的开发者来说,JavaScript 的原型继承往往感觉有些奇怪。主要优势在于它是动态的(您可以在创建后向原型和对象添加新属性);没有菱形问题(您永远不会因为多个祖先而出现歧义);并且在没有僵化结构的情况下简单地做您想做的事情要简单得多。我们已经看到 Three.js 使用了一个继承模式;您也可以在代码中使用面向对象模式,了解 JavaScript 在这方面而不是与其弱点作斗争的优势会有所帮助。

强烈推荐使用 Grunt (gruntjs.com/)来简化您的发布和测试工作流程,因为它有助于最小化从做出更改到在实际操作中测试它之间的时间。Grunt 是一个命令行工具,执行预定义的任务,因此您可以轻松地执行其他发布步骤。例如,生产代码应该被压缩、连接,并检查语法错误,Grunt 可以使用 UglifyJS 和 JSHint 项目来完成这些任务。如果您的项目是开源的、足够大,或者有足够的人需要单独的文档,JSDuck (github.com/senchalabs/jsduck)是一个有用的工具,可以从代码注释中自动生成它(Grunt 也支持它)。您可以在flippinawesome.org/2013/07/01/building-a-javascript-library-with-grunt-js/找到关于如何开始使用 Grunt 的精彩教程。

现有的工作流程和关卡开发

为了制作基于 Three.js 的游戏,工作室现有的游戏开发流程可能不需要太多更改。一些程序员可能甚至不需要学习 JavaScript,如果他们已经熟悉它,因为许多语言编译成 JavaScript。现有的流程可以保持不变,用于测试和生成,以及开发提案、概念、分镜脚本、模型、纹理、声音和其他资源。最大的挑战在于组装这些资源——将关卡构建到 Three.js 场景中。目前还没有为需要它们的 Three.js 游戏设计关卡的优秀工具,部分原因是因为 Three.js 是一个图形库,而游戏有很多难以很好地为单个工具概括的自定义要求。关卡编辑器通常与游戏引擎及其提供的类紧密相关。

原始的 Three.js 作者创建了一个场景编辑器,您可以在mrdoob.github.io/three.js/editor/尝试使用。这对于小型项目很有用,但对于大型项目来说很快就会变得难以控制(尤其是在多人同时在一个项目上工作时)。此外,场景编辑器无法处理自定义对象,如出生点,因此如果您使用它,至少每个级别的部分将需要使用自定义代码来定义。

因此,如果你需要创建多层结构或者需要以可视化的方式创建,你可能需要构建自己的场景布局工具。你可以通过几种方式来实现这一点。首先,Three.js 场景编辑器是 Three.js 项目的一部分(位于 editor 文件夹中),所以你可以从这里开始并对其进行编辑。其次,你可以尝试为现有的级别开发工具编写导出器或为其保存文件编写转换器,然后编写自定义的 Three.js 加载器。第三,你可以尝试从头开始编写自己的工具。好消息是,一旦你编写了一个,你就可以在其他项目中再次使用它。

小贴士

显然,如果你打算构建多个 Three.js 项目,编写可重用组件是有帮助的。在编写自己的组件之前,你可能想查看由 Jerome Etienne 开发的两个 Three.js 辅助库:一个名为 tQuery 的扩展系统,以及一系列称为 THREEx 的实用工具,分别可在 jeromeetienne.github.io/tquery/jeromeetienne.github.io/threex/ 找到。

Voxel.js (voxeljs.com/) 是一个拥有自己级别编辑器的游戏引擎的好例子。它还包含许多模块(包括一个多人模块),你可以将其插入。它专为类似 Minecraft 的游戏设计,但你可能也能将其作为其他大型项目的起点。

摘要

在本章中,我们学习了设计和开发高质量网页游戏。我们涵盖了游戏设计和开发中独特的网络方面,以及 Three.js 如何支持这些方面;重要的性能考虑因素;以及基本的客户端-服务器和同步网络。

你现在已经准备好拥抱下一代游戏了。恭喜你!

http://www.hskmm.com/?act=detail&tid=38660

相关文章:

  • 2025年靠谱的低温伴热带,铠装伴热带厂家推荐及采购指南
  • 2025年知名的污水格栅机,格栅机品牌厂家排行榜
  • 2025年比较好的渡线煤矿道岔,盾构施工煤矿道岔厂家最新推荐权威榜
  • 【Azure Entra ID】当Entra ID中的用户所属Group数量超过200个之后的问题
  • 2025年靠谱的聚酯切片吨袋,危化品吨袋厂家最新TOP实力排行
  • 2025 年国内连接器厂家最新推荐榜:中国电子元件行业协会测评认证,覆盖多领域的优质制造商精选
  • Paper: Extracting alignment data in open models
  • php直播源码,写代码实现缩进的快捷方式 - 云豹科技
  • 2025年知名的逆变器高压直流继电器,航空航天高压直流继电器厂家最新实力排行
  • Qt6学习入门——环境搭建
  • 2025年知名的助力机械手,桁架机械臂品牌厂家排行榜
  • 2025年防裂贴抗裂贴源头厂家权威推荐榜单:沥青路面抗裂贴/自粘式抗裂贴/抗裂贴源头厂家精选
  • XXL-JOB(7)
  • 2025年热门的精工智能定制五金,高端定制五金最新TOP品牌厂家排行
  • 2025年评价高的白色挤塑板,挤塑板厂家实力及用户口碑排行榜
  • 2025年质量好的制冷压缩机设备,活塞式制冷压缩机厂家最新热销排行
  • 2025年靠谱的风电驱鸟器,冲击波驱鸟器用户好评厂家排行
  • 2025年循环烘箱厂家最新企业推荐榜,热风循环烘箱厂家,聚焦服务品质与设备竞争力深度剖析
  • 邢台华电数控:车铣复合厂家技术应用与服务能力解析
  • 2025年靠谱的三联托辊,槽型托辊厂家推荐及选择参考
  • 2025年10月大路灯产品推荐榜:公牛领衔十强对比 。
  • 2025年干燥机厂家最新综合评估榜:聚焦技术实力与产品专业性深度剖析
  • 2025年热门的高强度锌钢阳台栏杆,阳台栏杆厂家最新TOP排行榜
  • 2025年常州干燥设备企业最新推荐榜,聚焦企业服务品质与产品竞争力深度剖析
  • 2025年评价高的微动开关,防水微动开关厂家推荐及采购指南
  • 2025年10月北京生殖咨询公司评测榜:美月国际咨询数据解析
  • 2025年评价高的不锈钢烘焙凉网架,不锈钢定制网厂家最新权威实力榜
  • 2025年10月素材网站评测:高性价比正版资源榜
  • 2025年10月大路灯产品推荐榜:十款主流型号对比评价
  • AI股票预测分析报告 - 2025年10月25日 - 10:02:39