Discover three.js is now open source!
Word Count:1259, reading time: ~6minutes

介绍世界应用程序

在本书中,我们的目标是创建简单但功能齐全的 three.js 应用程序,类似于您在专业环境中可能创建的应用程序。完成这些章节后,您将能够使用您在此处学到的内容创建任何大小的面向客户的精美 Web 应用程序,无论是 3D 产品展示令人惊叹的官网页面视频游戏游戏引擎音乐视频3D 或 CAD 软件新闻可视化,或几乎 任何您可以梦想的东西。不仅如此,您还可以 立即 使用这些章节中的代码作为构建您自己的应用程序的模板。

在上一章中,我们创建了第一个 three.js 应用程序,并在此过程中介绍了许多新的 three.js 和计算机图形信息。但是,我们没有注意我们编写的代码的质量或结构。在这里,我们将重构这个简单的单体应用程序以创建一个模板,我们可以将其用作本书其余示例的起点。为了确保我们的代码保持可访问性和易于理解,我们将应用程序拆分为多个小模块,每个模块处理复杂整体中的一小部分。

HTML 和 CSS 文件将保持不变,这里只需要重构 JavaScript。

模块化软件设计

在编写模块化 JavaScript 时,每个文件都是一个模块。因此,我们可以通过文件名来引用模块,例如 main.js,或者简单地称为 模块。模块化软件设计的一个重要部分是选择模块的结构和名称。打开内联代码编辑器,您会看到本章所需的所有文件都已创建,尽管它们一开始都是空的。如果您愿意,请点击比较开关以查看已完成的代码,否则,请在阅读时尝试自己完成模块。

附录中有一整章专门介绍 JavaScript 模块。如果这个主题对你来说是新的,那么现在是查看它的好时机。

网页和世界应用程序

在前两章中,我们创建了一个由 index.htmlmain.css 组成的基本网页,然后我们在 main.js 中编写了我们的 three.js 应用程序。但是,如果您还记得,在 0.7 中:将 three.js 与 React、Vue.js、Angular、Svelte、TypeScript 一起使用…,我们说过我们的目标是创建一个可以放入任何网络应用程序的组件,就像它可以与像这样的简单网页一起使用一样容易。为此,我们需要添加另一个小的抽象层。我们将从删除 main.js 中的所有内容开始。现在,我们有一个简单的 Web 应用程序,由三个文件组成: index.htmlmain.cssmain.js (目前为空)。我们会制定一个规则:这个 web 应用程序不能知道 three.js 的存在。一旦我们构建了我们的 three.js 应用程序,所有 Web 应用程序都应该知道我们有一个能够生成 3D 场景的组件,但不知道该组件是 如何 生成的。在现实世界中,这个 Web 应用程序可能要复杂得多,并且使用诸如 React 或 Svelte 之类的框架构建。但是,使用我们的 three.js 组件不会比这里更复杂。

为此,我们将把与 three.js 相关的所有内容移动到一个单独的应用程序(或组件)中,我们将把它放在 src/World 文件夹中。在这个文件夹中,我们可以随意使用 three.js,但是在这个文件夹之外,我们将被禁止使用 three.js。此外,此文件夹中的文件应形成一个独立的组件,该组件对显示它的 Web 应用程序一无所知。这意味着我们可以拿着 World/ 文件夹,然后将其放入任何 Web 应用程序中,无论是像这样的简单 HTML 页面,还是使用 React、Angular 或 Vue 等框架制作的应用程序。这样想吧:您应该能够将您的 three.js 组件提供给另一个对 three.js 一无所知的开发人员,并在五分钟或更短的时间内跟他们解释如何将其集成到他们的 Web 应用程序中,而无需解释如何实现 three.js 工作。

从这里开始,我们将此文件夹及其内容称为**World 应用程序**。

世界应用

目前,我们的 three.js 场景比较简单。要设置它,我们需要再次遵循 上一章中概述的六步程序:

  1. 初始设置
  2. 创建场景
  3. 创建相机
  4. 创建立方体并将其添加到场景中
  5. 创建渲染器
  6. 渲染场景

但是,使用 世界应用程序应如下所示:

  1. 创建 World 应用程序的实例
  2. 渲染场景

第一组六个任务是 执行细节。第二组两个任务是我们将要提供给包含 Web 应用程序的 接口

World 接口

目前接口非常简单。在 main.js 中使用它看起来像这样:

main.js: 创建world实例
    


// 1. Create an instance of the World app
const world = new World(container);

// 2. Render the scene
world.render();



  

应该隐藏与 实现 世界应用程序不相关的所有内容。在 main.js 中,我们应该无法访问场景、相机、渲染器或立方体。如果我们以后需要添加额外的功能,我们将通过扩展接口来实现,而 不是 通过向外界公开 three.js 函数来实现。

请注意,我们将一个容器传递给 World 构造函数,它将再次成为我们的场景容器。在 World 中,我们将把画布附加到这个容器中, 就像我们在上一章中所做的那样

在阅读下一部分之前,如果需要,请查看附录以 复习 JavaScript 类

World

现在,我们可以继续并开始构建World类。我们需要一个constructor方法来处理设置(创建场景、渲染器、立方体和相机,设置场景的大小,并将画布元素添加到容器中),以及一个render方法来渲染场景。打开或创建 src/World/World.js 模块,创建 World 类,并在其中添加这两个方法。在文件的底部,导出类,以便我们可以从 main.js 中使用它。

World.js: 初始设置
    
class World {
// 1. Create an instance of the World app
constructor(container) {}

// 2. Render the scene
render() {}
}

export { World };

  

至此,我们的接口就完成了。其他一切都是实现。虽然这个接口还没有 任何事情,但它已经 可以使用了 。换句话说,我们可以继续完全设置 main.js,在适当的地方调用这些函数。稍后,一旦我们填写详细信息,该应用程序将神奇地开始工作。这是创建接口的常用方法。首先,决定它的外观并为接口的每个部分创建存根,然后 关注细节。

设置 main.js

main.js 中,现在应该是空的,我们将首先导入新的 World 类,然后我们将创建一个 main 函数并立即调用它来启动应用程序:

main.js: 初始设置
    
import { World } from './World/World.js';

// create the main function
function main() {
// code to set up the World App will go here
}

// call main to start the app
main();

  

设置 World 应用程序

接下来,我们将执行两步 World 应用程序设置。首先,就像上一章一样,我们需要一个对容器的引用。然后我们将创建一个new World,最后,一切都设置好了,我们可以调用world.render来绘制场景。

main.js: 创建一个全新的世界
    function main() {
  // Get a reference to the container element
  const container = document.querySelector('#scene-container');

  // 1. Create an instance of the World app
  const world = new World(container);

  // 2. Render the scene
  world.render();
}

  

至此,main.js 模块就完成了。稍后,当我们填写 World 应用程序的详细信息时,我们的场景就会栩栩如生。

World 应用程序实现

当然,构建接口是很容易的部分。现在我们必须让它工作。幸运的是,从这里开始,主要是从前一章复制代码。再次查看这些我们设置的任务。

  1. 初始设置
  2. 创建场景
  3. 创建相机
  4. 创建立方体并将其添加到场景中
  5. 创建渲染器
  6. 渲染场景

第一个完成并划掉。剩下的是后面五个。但是,我们将创建一个附加任务,该任务将在步骤 5 和 6 之间进行:

  • 设置场景的大小。

我们将为每个剩余的任务创建一个新模块。目前,这些模块将非常简单,但随着应用程序规模的扩大,它们可能会变得更加复杂。像这样将它们拆分意味着复杂性永远不会变得不堪重负,World 类将保持可控,而不是螺旋式上升到千行级的厄运。

我们将这些模块分为两类:组件components和系统systems。组件是可以放置到场景中的任何东西,例如立方体、相机和场景本身,而系统是在组件或其他系统上运行的东西。在这里,是渲染器和大小调整函数,我们将其称为Resizer. 稍后您可能想要添加其他类别,例如实用程序utilities、商店stores等。

这为我们提供了以下新模块:

  • components/camera.js
  • components/cube.js
  • components/scene.js
  • systems/renderer.js
  • systems/Resizer.js

如果您在本地工作,请立即创建这些文件,否则,请在编辑器中找到它们。Resizer有一个大写的R因为它将是一个类。其他四个模块每个都包含一个遵循这个基本模式的函数:

我们大多数新模块的基本模式
    
import { Item } from 'three';

function createItem() {
const instance = new Item();

return instance;
}

export { createItem }

  

…其中createItem替换为createCamera, createCube, createRenderer, 或createScene。如果您不清楚这些模块中的任何代码,请返回上一章详细解释。

Systems: Renderer 模块

首先是 渲染器系统:

systems/renderer.js
    import { WebGLRenderer } from 'three';

function createRenderer() {
  const renderer = new WebGLRenderer();

  return renderer;
}

export { createRenderer };


  

稍后,我们将调整渲染器的一些设置以提高渲染质量,但现在,具有默认设置的基本渲染器就可以了。

Components: Scene 模块

接下来,场景组件:

components/scene.js
    import { Color, Scene } from 'three';

function createScene() {
  const scene = new Scene();

  scene.background = new Color('skyblue');

  return scene;
}

export { createScene };


  

在这里,我们创建了Scene该类的一个实例,然后使用Color将背景设置为skyblue,就像我们 在上一章中所做的那样。

Components: Camera 模块

第三个是 相机组件:

components/camera.js
    import { PerspectiveCamera } from 'three';

function createCamera() {
  const camera = new PerspectiveCamera(
    35, // fov = Field Of View
    1, // aspect ratio (dummy value)
    0.1, // near clipping plane
    100, // far clipping plane
  );

  // move the camera back so we can view the scene
  camera.position.set(0, 0, 10);

  return camera;
}

export { createCamera };


  

这与我们在上一章中用于设置相机的代码 几乎 相同,只是这次我们使用了一个虚拟值1作为纵横比,因为它依赖于container的尺寸。我们想避免不必要地传递东西,所以我们将推迟设置纵横比,直到后面我们创建Resizer系统。

另一个区别:在上一章中,我们将相机的四个参数中的每一个都声明为变量,然后将它们传递给构造函数。在这里,我们切换到将它们声明为内联以节省一些空间。将此代码与上一章进行比较以查看差异。

Ch 1.2: 你的第一个three.js场景:创建相机
    // Create a camera
const fov = 35; // AKA Field of View
const aspect = container.clientWidth / container.clientHeight;
const near = 0.1; // the near clipping plane
const far = 100; // the far clipping plane

const camera = new PerspectiveCamera(fov, aspect, near, far);

  

Components: Cube 模块

第四个是立方体组件,它包括创建 几何体材质网格。再次提醒,这里突出显示的行与上一章的代码相同。

components/cube.js
    import { BoxBufferGeometry, Mesh, MeshBasicMaterial } from 'three';

function createCube() {
  // create a geometry
  const geometry = new BoxBufferGeometry(2, 2, 2);

  // create a default (white) Basic material
  const material = new MeshBasicMaterial();

  // create a Mesh containing the geometry and material
  const cube = new Mesh(geometry, material);

  return cube;
}

export { createCube };


  

稍后,我们可能会添加比这个简单立方体复杂得多的可见对象,在这种情况下,我们会将它们拆分为子模块。例如,游戏中的可玩角色可能是一个包含许多独立部分的复杂组件,因此我们将其放入 components/mainCharacter/ 中,其中我们将有诸如 mainCharacter/geometry.jsmainCharacter/materials.jsmainCharacter/animations.js 等之类的子模块。

Systems: Resizer 模块

最后,我们将为Resizer模块创建一个存根。这个与其他的有点不同,因为它是一个类而不是一个函数(请注意,文件名以大写 R 开头表示它是一个类):

systems/Resizer.js: 初始设置
    


class Resizer {
  constructor() {}
}

export { Resizer };



  

我们将在下面完成这门课。

设置World

有了这个,我们的大部分组件components和系统systems都准备好了,我们可以填写World类的详细信息。首先,导入我们刚刚在 World.js 上面创建的五个模块:

World.js: 模块导入
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

  

设置相机、渲染器和场景

接下来,我们将设置相机、场景和渲染器,它们都需要在构造函数中创建,然后在World.render方法中访问。通常,这意味着我们会将它们保存为类成员变量:this.camerathis.scenethis.renderer

类成员变量可从类外部访问
    


class World {
  constructor() {
    this.camera = createCamera();
    this.scene = createScene();
    this.renderer = createRenderer();
  }



  

但是,成员变量可以在 main.js 中访问,这是我们 不想 要的。

main.js: 不是我们想要的
    


const world = new World();

// We can access member variables from the instance
console.log(world.camera);
console.log(world.renderer);
console.log(world.scene);



  

好好保护你的秘密

我们希望 使用我们设计的接口与 World 应用程序进行交互,并且我们希望隐藏其他所有内容。为什么?想象一下,您长期努力地创建了一个美观、结构良好的 three.js 应用程序,然后您将其传递给您的客户端,以便他们集成到一个更大的应用程序中。他们对 three.js 一无所知,但他们是称职的开发人员,因此当他们需要更改某些内容时,他们开始翻找代码,并最终弄清楚他们可以访问相机和渲染器。他们打开了 three.js 文档,在阅读了五分钟后,更改了一些设置。这些可能会破坏应用程序的其他一些部分,因此它们会进行更多更改,更多更改,最终……混乱。 将被要求修复。

通过将实现隐藏在一个简单的接口后面,您可以使您的应用程序万无一失且易于使用。它只做它应该做的,没有别的 通过隐藏实现,我们对使用我们代码的人实施了良好的编码风格。您可以访问的实现越多,它就越有可能成为您以后必须处理的复杂的半生不熟的“修复”。

六个月后用 替换 客户 这个词,一切仍然有效。如果您以后需要对应用程序进行一些快速更改,如果您除了简单的接口之外无法访问任何东西,您就不会想以一种 hacky 的方式进行更改。相反,您必须打开 World 应用程序并 正确 修复问题(至少在理论上)。

当然,有时您确实想将相机和其他组件暴露给外界。但是,隐藏它们应该是默认设置。保护好你的秘密,只有在你有充分理由这样做时才公开它们。

但是该怎么做呢?

大多数语言都有私有类字段让你可以这么做,并且它们也将很快出现在 JavaScript 中。不幸的是,在撰写本章时, 支持并不好,所以现在我们必须寻找替代方案。

模块作用域变量

我们可以通过在 模块作用域内声明变量来创建类似于私有变量的东西:

World.js: 将相机、渲染器和场景创建为模块作用域变量
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

// These variables are module-scoped: we cannot access them
// from outside the module
let camera;
let renderer;
let scene;

class World {
  constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
  }
  

这样,我们可以从 World 模块中的任何位置访问camerarenderer,但 不能main.js 访问。这正是我们想要的。

重要说明:如果我们创建World类的 两个 实例,此解决方案将不起作用,因为模块作用域变量将在两个实例之间共享,因此第二个实例将覆盖第一个实例的变量。然而,我们只打算一次创建一个世界,所以我们会接受这个限制。

将画布添加到容器中

这样,我们的大部分设置就完成了。我们现在有了相机、场景和渲染器。 如果您还记得上一章,当我们创建渲染器时,<canvas>元素也会被创建并存储在renderer.domElement中。 下一步是将其添加到容器中。

World.js: 将画布附加到容器
    


class World {
  constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);
  }



  

渲染场景

接下来,我们将设置World.render以便我们可以看到结果。代码再次与 上一章相同。

World.js: 完成渲染方法
      render() {
    // draw a single frame
    renderer.render(scene, camera);
  }

  
画布是红色矩形

完成此操作后,如果一切设置正确,您的场景将被绘制到画布中。然而,cavnas 并没有占据容器的全部大小,因为我们还没有完成Resizer。相反,它是以<canvas>元素的默认大小创建的,即$300 \times 150$像素(至少在 Chrome 中)。

这不会很明显,因为我们已经将容器背景设置为与场景背景相同的颜色——它们都是“天蓝色”。但是,尝试暂时将画布设置为“红色”,这将变得很明显。

scene.js: 暂时将画布设为红色以表明它还没有占据整个容器
    


scene.background = new Color("red");



  

我们稍后会解决这个问题,但首先,让我们将立方体添加到场景中。

创建立方体

立方体不需要是模块作用域变量,因为它只在构造函数中使用,所以调用createCube,将结果保存在一个名为cube的普通变量中,然后将其添加到场景中。

World.js: 创建立方体并将其添加到场景中
      constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);

    const cube = createCube();

    scene.add(cube);
  }
  

现在,白色方块将出现在蓝色背景上。仍然大小为$300 \times 150$像素虽然。

Systems: Resizer 模块

剩下的就是设置Resizer类。收集上一章中我们用来设置场景大小的所有代码,我们得到以下内容:

Resizer类中我们所需要做的
    


// Set the camera's aspect ratio to match the container's proportions
camera.aspect = container.clientWidth / container.clientHeight;

// next, set the renderer to the same size as our container element
renderer.setSize(container.clientWidth, container.clientHeight);

// finally, set the pixel ratio to ensure our scene will look good on mobile devices
renderer.setPixelRatio(window.devicePixelRatio);



  

在这里,我们将把这些行移到Resizer类中。为什么是一个类(为什么是 Re-sizer)?稍后,这个类会有更多的工作要做,例如,在 1.6:让我们的场景具有响应性(以及处理锯齿)中,我们将在浏览器窗口改变大小时设置自动调整大小。将它创建为一个类为我们提供了更多的空间,可以在以后添加功能而无需重构。

通过以上几行,我们可以看到Resizer需要容器、相机和渲染器(devicePixelRatio全局作用域内,这意味着它无处不在)。在 World 中,确保Resizer在导入列表中:

World.js: 模块导入
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

  

…然后在构造函数中创建一个resizer实例:

World.js: 创建resizer
      constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);

    const cube = createCube();

    scene.add(cube);

    const resizer = new Resizer(container, camera, renderer);
  }

  

接下来,将我们在上一章收集的代码行复制到构造函数中,并更新方法的签名以包括容器、相机和渲染器。

Resizer.js: 几乎完成!
    


class Resizer {
  constructor(container, camera, renderer) {
    // Set the camera's aspect ratio
    camera.aspect = container.clientWidth / container.clientHeight;

    // update the size of the renderer AND the canvas
    renderer.setSize(container.clientWidth, container.clientHeight);

    // set the pixel ratio (for mobile devices)
    renderer.setPixelRatio(window.devicePixelRatio);
  }
}



  

这几乎完成了,尽管我们还需要做一件事。如果你回想一下上一章,相机使用纵横比以及视野以及近远裁剪平面来计算它的 视锥平截头体不会自动重新计算,因此当我们更改存储在camera.aspectcamera.fovcamera.nearcamera.far中的任何这些设置时,我们还需要更新平截头体。

相机将其平截头体存储在称为 投影矩阵的数学对象中,为了更新它,我们需要调用相机的 .updateProjectionMatrix方法。添加这一行为我们提供了完成 Resizer 类的最后一行:

Resizer.js: 完成!
    class Resizer {
  constructor(container, camera, renderer) {
    // Set the camera's aspect ratio
    camera.aspect = container.clientWidth / container.clientHeight;

    // update the camera's frustum
    camera.updateProjectionMatrix();

    // update the size of the renderer AND the canvas
    renderer.setSize(container.clientWidth, container.clientHeight);

    // set the pixel ratio (for mobile devices)
    renderer.setPixelRatio(window.devicePixelRatio);
  }
}

  
终于全尺寸了!

这样,我们的重构就完成了,场景将扩大到占据整个窗口的大小。

最终的World

一切就绪后,这是 World.js 模块的完整代码。如您所见,此类协调着我们的 3D 场景的设置,同时将复杂性转移到单独的模块上。

World.js: 完整代码
    import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createScene } from './components/scene.js';

import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';

// These variables are module-scoped: we cannot access them
// from outside the module
let camera;
let renderer;
let scene;

class World {
  constructor(container) {
    camera = createCamera();
    scene = createScene();
    renderer = createRenderer();
    container.append(renderer.domElement);

    const cube = createCube();

    scene.add(cube);

    const resizer = new Resizer(container, camera, renderer);
  }

  render() {
    // draw a single frame
    renderer.render(scene, camera);
  }
}

export { World };


  

哇!就是这些重构!如果你习惯于使用模块来构建你的代码,那么这一章可能会轻而易举。另一方面,如果这对您来说是全新的,那么可能需要一些时间来适应拆分这样的应用程序的想法。希望通过一步一步的完成,您现在可以更清楚地了解我们为什么选择这样做。

我们的应用程序现在可以启动了。在接下来的几章中,我们将添加光照、移动、用户控件、动画,甚至是一些比我们简陋的正方形更有趣的形状。你准备好了吗?

挑战

Import Style
Selected Texture