您是否希望开始在网络上使用 3D?在本教程中,我们将介绍如何使用 Three.js 创建一个 3D 角色,添加一些简单但有效的动画,以及一个生成式调色板。

作者: Michelle Barker[1]

three.js 3d模型用什么做的(译文在Three.js)(1)

Three.js是一个 JavaScript 库,用于使用 WebGL 进行 3D 绘图。它使我们能够将 3D 对象添加到场景中,并操纵位置和照明等内容。如果您是一个习惯于使用 CSS 处理 DOM 和样式元素的开发人员,那么 Three.js 和 WebGL 看起来像是一个全新的世界,而且可能有点吓人!本文适用于熟悉 JavaScript 但对 Three.js 相对较新的开发人员。我们的目标是通过 Three.js(一个 3D 动画人物)构建一些简单但有效的东西,以掌握基本原理,并证明一点知识可以带您走很长的路!

设置场景

在 Web 开发中,我们习惯于为 DOM 元素设置样式,我们可以在浏览器开发工具中检查和调试这些元素。在 WebGL 中,所有内容都呈现在单个<canvas>元素中。就像视频一样,一切都只是改变颜色的像素,所以没有什么需要检查的。如果您检查一个完全使用 WebGL 呈现的网页,您将看到的只是一个<canvas>元素。我们可以使用 Three.js 之类的库通过 JavaScript 在画布上绘图。

基本原则

首先我们要设置场景。如果您已经对此感到满意,您可以跳过这部分并直接跳到我们开始创建 3D 角色的部分。

我们可以将 Three.js 场景视为一个 3D 空间,我们可以在其中放置一个摄像头和一个供其查看的对象。

three.js 3d模型用什么做的(译文在Three.js)(2)

绘制一个透明立方体,里面有一个较小的立方体,显示 x、y 和 z 轴和中心坐标

我们可以把我们的场景想象成一个巨大的立方体,物体放在中心。事实上,它是无限延伸的,但我们能看到多少是有限度的。

首先我们需要创建场景。在我们的 HTML 中,我们只需要一个<canvas>元素:

<canvas data-canvas></canvas>

现在我们可以使用相机创建场景,并在 Three.js 中将其渲染到画布上:

const canvas = document.querySelector('[data-canvas]') // Create the scene const scene = new THREE.Scene() // Create the camera const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 1000) scene.add(camera) // Create the renderer const renderer = new THREE.WebGLRenderer({ canvas }) // Render the scene renderer.setSize(window.innerWidth, window.innerHeight) renderer.render(scene, camera)

为简洁起见,我们不会详细介绍我们在这里所做的一切。该文档有更多关于创建场景和各种相机属性的详细信息。但是,我们要做的第一件事是移动相机的位置。默认情况下,我们添加到场景中的任何东西都将放置在坐标 (0, 0, 0) 上——也就是说,如果我们将场景本身想象成一个立方体,我们的相机将被放置在中心。让我们将相机放在更远一点的地方,这样我们的相机就可以看到放置在场景中心的任何物体。

three.js 3d模型用什么做的(译文在Three.js)(3)

image.png

将相机从中心移开可以让我们看到放置在场景中心的对象。

我们可以通过设置相机的z位置来做到这一点:

camera.position.z = 5

我们还没有看到任何东西,因为我们还没有在场景中添加任何对象。让我们在场景中添加一个立方体,它将构成我们图形的基础。

3D 形状

Three.js 中的对象称为网格。为了创建网格,我们需要两件事:几何材质。几何图形是 3D 形状。Three.js 有一系列可供选择的几何图形,可以以不同的方式进行操作。出于本教程的目的——看看我们可以用一些基本原理制作哪些有趣的场景——我们将把自己限制在两种几何形状:立方体和球体。

让我们在场景中添加一个立方体。首先,我们将定义几何和材料。使用 Three.js BoxGeometry,我们传入 x、y 和 z 维度的参数。

// Create a new BoxGeometry with dimensions 1 x 1 x 1 const geometry = new THREE.BoxGeometry(1, 1, 1)

对于材质,我们将选择MeshLambertMaterial,它对光线和阴影有反应,但比其他一些材质更高效。

// Create a new material with a white color const material = new THREE.MeshLambertMaterial({ color: 0xffffff })

然后我们通过组合几何体和材质来创建网格,并将其添加到场景中:

const mesh = new THREE.Mesh(geometry, material) scene.add(mesh)

不幸的是,我们仍然什么也看不到!那是因为我们使用的材料取决于光才能被看到。让我们添加一个定向光,它会从上方照射下来。我们将传入两个参数:0xffffff颜色(白色)和强度,我们将其设置为 1。

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1) scene.add(lightDirectional)

three.js 3d模型用什么做的(译文在Three.js)(4)

image.png

默认情况下,灯光从上方指向下方

如果您到目前为止已按照所有步骤操作,您仍然不会看到任何内容!那是因为光线直接指向我们的立方体,所以正面处于阴影中。如果我们将光的 z 位置移向相机并偏离中心,我们现在应该看到我们的立方体。

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1) scene.add(lightDirectional) // Move the light source towards us and off-center lightDirectional.position.x = 5 lightDirectional.position.y = 5 lightDirectional.position.z = 5

three.js 3d模型用什么做的(译文在Three.js)(5)

image.png

移动灯光让我们有更好的视野

我们也可以通过调用同时设置 x、y 和 z 轴上的位置set():

lightDirectional.position.set(5, 5, 5)

我们正直视立方体,所以只能看到一张脸。如果我们稍微旋转一下,我们可以看到其他面孔。要旋转一个对象,我们需要在 radians 中给它一个旋转角度。我不了解你,但我发现弧度不是很容易可视化,所以我更喜欢使用 JS 函数来转换度数:

const degreesToRadians = (degrees) => { return degrees * (Math.PI / 180) } mesh.rotation.x = degreesToRadians(30) mesh.rotation.y = degreesToRadians(30)

我们还可以添加一些带有颜色色调的环境光(来自各个方向的光),这会稍微柔化效果 end 确保远离光的立方体的面不会完全隐藏在阴影中:

const lightAmbient = new THREE.AmbientLight(0x9eaeff, 0.2) scene.add(lightAmbient)

现在我们已经设置了基本场景,我们可以开始创建 3D 角色了。为了帮助您入门,我创建了一个样板,其中包括我们刚刚完成的所有设置工作,以便您可以根据需要直接跳到下一部分。

创建一个类

我们要做的第一件事是为我们的图形创建一个类。这将使通过实例化类向我们的场景中添加任意数量的图形变得容易。我们将给它一些默认参数,稍后我们将使用这些参数在场景中定位我们的角色。

class Figure { constructor(params) { this.params = { x: 0, y: 0, z: 0, ry: 0, ...params } } }

团体

在我们的类构造函数中,让我们创建一个 Three.js 组并将其添加到我们的场景中。创建一个组允许我们将多个几何图形作为一个整体进行操作。我们将把我们身材的不同元素(头部、身体、手臂等)添加到这个组中。然后我们可以在场景中的任何位置定位、缩放或旋转图形,而不必每次都单独定位这些部分。

class Figure { constructor(params) { this.params = { x: 0, y: 0, z: 0, ry: 0, ...params } this.group = new THREE.Group() scene.add(this.group) } }

创建身体部位

接下来让我们编写一个函数来渲染我们的图形主体。它将与我们之前创建立方体的方式大致相同,除了我们将通过增加y轴上的大小使其更高一些。(当我们这样做的时候,我们可以删除我们之前创建立方体的代码行,从一个清晰的场景开始。)我们已经在我们的代码库中定义了材质,不需要在类本身。

我们没有将身体添加到场景中,而是将其添加到我们创建的组中。

const material = new THREE.MeshLambertMaterial({ color: 0xffffff }) class Figure { constructor(params) { this.params = { x: 0, y: 0, z: 0, ry: 0, ...params } this.group = new THREE.Group() scene.add(this.group) } createBody() { const geometry = new THREE.BoxGeometry(1, 1.5, 1) const body = new THREE.Mesh(geometry, material) this.group.add(body) } }

我们还将编写一个类方法来初始化图形。到目前为止,它只会调用该createBody()方法,但我们很快会添加其他方法。(除非另有说明,否则此方法和所有后续方法都将写入我们的类声明中。)

createBody() { const geometry = new THREE.BoxGeometry(1, 1.5, 1) const body = new THREE.Mesh(geometry, material) this.group.add(body) } init() { this.createBody() }

将人物添加到场景中

在这一点上,我们要在场景中渲染我们的人物,以检查一切是否正常。我们可以通过实例化类来做到这一点。

const figure = new Figure() figure.init()

接下来我们将编写一个类似的方法来创建我们角色的头部。我们将把它做成一个立方体,略大于身体的宽度。我们还需要调整位置,使其刚好在身体上方,并在我们的init()方法中调用该函数:

createHead() { const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4) const head = new THREE.Mesh(geometry, material) this.group.add(head) // Position it above the body head.position.y = 1.65 } init() { this.createBody() this.createHead() }

您现在应该看到在第一个立方体(头部)下方呈现的更窄的长方体(主体)。

添加手臂

现在我们要给我们的角色一些武器。这是事情变得稍微复杂的地方。我们将向我们的类添加另一个名为createArms(). 同样,我们将定义一个几何图形和一个网格。手臂将是长而薄的长方体,因此我们将传递我们想要的尺寸。

由于我们需要两个手臂,我们将在for循环中创建它们。

createArms() { for(let i = 0; i < 2; i ) { const geometry = new THREE.BoxGeometry(0.25, 1, 0.25) const arm = new THREE.Mesh(geometry, material) this.group.add(arm) } }

我们不需要在 for 循环中创建几何图形,因为它对于每个手臂都是相同的。

不要忘记在我们的init()方法中调用该函数:

init() { this.createBody() this.createHead() this.createArms() }

我们还需要将每条手臂定位在身体的两侧。我发现在这里创建一个变量m(用于乘数)很有帮助。这有助于我们用最少的代码将左臂定位在x轴上的相反方向。 (稍后我们也会使用它来旋转手臂。)

createArms() { for(let i = 0; i < 2; i ) { const geometry = new THREE.BoxGeometry(0.25, 1, 0.25) const arm = new THREE.Mesh(geometry, material) const m = i % 2 === 0 ? 1 : -1 this.group.add(arm) arm.position.x = m * 0.8 arm.position.y = 0.1 } }

此外,我们可以在for循环中旋转手臂,使它们以更自然的角度伸出(就像立方体人一样自然!):

arm.rotation.z = degreesToRadians(30 * m)

three.js 3d模型用什么做的(译文在Three.js)(6)

如果我们的图形被放置在中心,左边的手臂将被定位在右边手臂的 x 轴位置的负等值处

旋转

当我们旋转手臂时,您可能会注意到它们从中心的原点旋转。使用静态演示可能很难看到,但请尝试在此示例中移动滑块。

我们可以看到手臂不会自然移动,与肩部成一定角度,而是整个手臂从中心旋转。在 CSS 中,我们只需设置transform-origin. Three.js 没有这个选项,所以我们需要做一些稍微不同的事情。

three.js 3d模型用什么做的(译文在Three.js)(7)

image.png

右图的手臂从顶部旋转,效果更自然

我们对每个手臂的步骤如下:

  1. 创建一个新的 Three.js 组。
  2. 将组定位在我们人物的“肩膀”(或我们想要旋转的点)。
  3. 为手臂创建一个新网格并将其相对于组定位。
  4. 旋转组(而不是手臂)。

让我们更新我们的createArms()函数以遵循这些步骤。首先,我们将为每个手臂创建组,将手臂网格添加到组中,并将组大致定位在我们想要的位置:

createArms() { const geometry = new THREE.BoxGeometry(0.25, 1, 0.25) for(let i = 0; i < 2; i ) { const arm = new THREE.Mesh(geometry, material) const m = i % 2 === 0 ? 1 : -1 // Create group for each arm const armGroup = new THREE.Group() // Add the arm to the group armGroup.add(arm) // Add the arm group to the figure this.group.add(armGroup) // Position the arm group armGroup.position.x = m * 0.8 armGroup.position.y = 0.1 } }

为了帮助我们可视化这一点,我们可以将 Three.js 的内置助手之一添加到我们的图形中。这将创建一个显示对象边界框的线框。帮助我们定位手臂很有用,一旦完成,我们就可以将其移除。

// Inside the `for` loop: const box = new THREE.BoxHelper(armGroup, 0xffff00) this.group.add(box)

要将变换原点设置为手臂的顶部而不是中心,我们需要将手臂(在组内)向下移动其高度的一半。让我们为高度创建一个变量,我们可以在创建几何图形时使用它:

createArms() { // Set the variable const height = 1 const geometry = new THREE.BoxGeometry(0.25, height, 0.25) for(let i = 0; i < 2; i ) { const armGroup = new THREE.Group() const arm = new THREE.Mesh(geometry, material) const m = i % 2 === 0 ? 1 : -1 armGroup.add(arm) this.group.add(armGroup) // Translate the arm (not the group) downwards by half the height arm.position.y = height * -0.5 armGroup.position.x = m * 0.8 armGroup.position.y = 0.6 // Helper const box = new THREE.BoxHelper(armGroup, 0xffff00) this.group.add(box) } }

然后我们可以旋转手臂组。

// In the `for` loop armGroup.rotation.z = degreesToRadians(30 * m)

在这个演示中,我们可以看到手臂(正确地)从顶部旋转,以获得更逼真的效果。(黄色是边界框。)

眼睛

接下来,我们将给我们的图形一些眼睛,为此我们将使用Three.js 中的Sphere 几何体。我们需要传入三个参数:球体的半径,以及宽度和高度的分段数(此处显示的默认值)。

const geometry = new THREE.SphereGeometry(1, 32, 16)

由于我们的眼睛会非常小,我们可能会使用更少的片段,这对性能更好(需要更少的计算)。

让我们为眼睛创建一个新组。这是可选的,但它有助于保持整洁。如果我们稍后需要重新定位眼睛,我们只需要重新定位该组,而不是单独重新定位两只眼睛。再一次,让我们for循环创建眼睛并将它们添加到组中。由于我们希望眼睛与身体颜色不同,我们可以定义一种新材料:

createEyes() { const eyes = new THREE.Group() const geometry = new THREE.SphereGeometry(0.15, 12, 8) // Define the eye material const material = new THREE.MeshLambertMaterial({ color: 0x44445c }) for(let i = 0; i < 2; i ) { const eye = new THREE.Mesh(geometry, material) const m = i % 2 === 0 ? 1 : -1 // Add the eye to the group eyes.add(eye) // Position the eye eye.position.x = 0.36 * m } }

我们可以将眼睛组直接添加到图中。但是,如果我们决定稍后移动头部,最好让眼睛随之移动,而不是完全独立定位!为此,我们需要修改我们的createHead()方法以创建另一个组,包括头部的主立方体和眼睛:

createHead() { // Create a new group for the head this.head = new THREE.Group() // Create the main cube of the head and add to the group const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4) const headMain = new THREE.Mesh(geometry, material) this.head.add(headMain) // Add the head group to the figure this.group.add(this.head) // Position the head group this.head.position.y = 1.65 // Add the eyes by calling the function we already made this.createEyes() }

在该createEyes()方法中,我们需要将眼睛组添加到头部组,并根据我们的喜好定位它们。我们需要将它们在z轴上向前定位,这样它们就不会隐藏在头部的立方体内:

// in createEyes() this.head.add(eyes) // Move the eyes forwards by half of the head depth - it might be a good idea to create a variable to do this! eyes.position.z = 0.7

最后,让我们给我们的身材一些腿。我们可以用与眼睛大致相同的方式来创建这些。因为它们应该相对于身体定位,所以我们可以用与头部相同的方式为身体创建一个新组,然后将腿添加到它:

createLegs() { const legs = new THREE.Group() const geometry = new THREE.BoxGeometry(0.25, 0.4, 0.25) for(let i = 0; i < 2; i ) { const leg = new THREE.Mesh(geometry, material) const m = i % 2 === 0 ? 1 : -1 legs.add(leg) leg.position.x = m * 0.22 } this.group.add(legs) legs.position.y = -1.15 this.body.add(legs) }

场景定位

如果我们回到我们的构造函数,我们可以根据参数定位我们的图形组:

class Figure { constructor(params) { this.params = { x: 0, y: 0, z: 0, ry: 0, ...params } this.group.position.x = this.params.x this.group.position.y = this.params.y this.group.position.z = this.params.z this.group.rotation.y = this.params.ry } }

现在,传入不同的参数使我们能够相应地定位它。例如,我们可以给它一点旋转,并调整它的 x 和 y 位置:

const figure = new Figure({ x: -4, y: -2, ry: degreesToRadians(35) }) figure.init()

或者,如果我们想在场景中居中图形,我们可以使用 Three.jsBox3函数,它计算图形组的边界框。这条线将使图形水平和垂直居中:

new THREE.Box3().setFromObject(figure.group).getCenter(figure.group.position).multiplyScalar(-1)

使其具有生成性

目前我们的图都是一种颜色,看起来不是特别有趣。我们可以添加更多颜色,并采取额外步骤使其具有生成性,因此每次刷新页面时都会获得新的颜色组合!为此,我们将使用一个函数来随机生成一个介于最小值和最大值之间的数字。这是我从George Francis那里借来的,它允许我们指定是否需要整数或浮点值(默认为整数)。

const random = (min, max, float = false) => { const val = Math.random() * (max - min) min if (float) { return val } return Math.floor(val) }

让我们在类构造函数中为 head 和 body 定义一些变量。使用该random()函数,我们将为 0 到 360 之间的每个值生成一个值:

class Figure { constructor(params) { this.headHue = random(0, 360) this.bodyHue = random(0, 360) } }

我喜欢在处理颜色时使用HSL,因为它可以让我们很好地控制色调、饱和度和亮度。我们将定义头部和身体的材质,通过使用模板文字将随机色调值传递给hsl颜色函数,为每种材质生成不同的颜色。在这里,我正在调整饱和度和亮度值,因此身体将是鲜艳的颜色(高饱和度),而头部将更加柔和:

class Figure { constructor(params) { this.headHue = random(0, 360) this.bodyHue = random(0, 360) this.headMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.headHue}, 30%, 50%` }) this.bodyMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.bodyHue}, 85%, 50%)` }) } }

我们生成的色调范围从 0 到 360,这是一个完整的色轮周期。如果我们想缩小范围(对于有限的调色板),我们可以在最小值和最大值之间选择一个较低的范围。例如,0 到 60 之间的范围将选择光谱中红色、橙色和黄色端的色调,不包括绿色、蓝色和紫色。

如果我们选择的话,我们同样可以生成亮度和饱和度的值。

现在我们只需要用或应用我们的生成颜色替换material任何this.headMaterial引用this.bodyMaterial。我选择为头部、手臂和腿使用头部色调。

我们可以将生成参数用于不仅仅是颜色。在这个演示中,我为头部和身体的大小、手臂和腿的长度以及眼睛的大小和位置生成随机值。

动画

使用 3D 的部分乐趣在于让我们的对象在 3D 空间中移动,并且表现得像现实世界中的对象。我们可以使用Greensock动画库 (GSAP) 为我们的 3D 图形添加一些动画。

GSAP 更常用于为 DOM 中的元素设置动画。由于在这种情况下我们没有为 DOM 元素设置动画,因此需要一种不同的方法。GSAP 不需要元素来制作动画——它可以为 JavaScript 对象制作动画。正如 GSAP 论坛上的一篇文章所说,GSAP 只是“快速改变数字”。

我们将让 GSAP 完成更改图形参数的工作,然后在每一帧上重新渲染图形。为此,我们可以使用 GSAP 的ticker方法,该方法使用requestAnimationFrame. 首先,让我们为值设置动画ry(我们的图形在 y 轴上的旋转)。我们将其设置为无限重复,持续时间为 20 秒:

gsap.to(figure.params, { ry: degreesToRadians(360), repeat: -1, duration: 20 })

我们还不会看到任何变化,因为我们没有重新渲染我们的场景。现在让我们在每一帧上触发重新渲染:

gsap.ticker.add(() => { // Update the rotation value figure.group.rotation.y = this.params.ry // Render the scene renderer.setSize(window.innerWidth, window.innerHeight) renderer.render(scene, camera) })

现在我们应该看到图形在场景中心的 y 轴上旋转。让我们也给他一些弹跳动作,通过上下移动他并旋转手臂。首先,我们将他在 y 轴上的起始位置设置得稍微靠下一点,这样他就不会跳出屏幕。我们将设置yoyo: true我们的补间,以便动画反向重复(所以我们的图形会上下反弹):

// Set the starting position gsap.set(figure.params, { y: -1.5 }) // Tween the y axis position and arm rotation gsap.to(figure.params, { y: 0, armRotation: degreesToRadians(90), repeat: -1, yoyo: true, duration: 0.5 })

由于我们需要更新一些东西,让我们创建一个bounce()在我们的Figure类上调用的方法,它将处理动画。我们可以使用它来更新旋转和位置的值,然后在我们的代码中调用它,以保持整洁:

/* In the Figure class: */ bounce() { this.group.rotation.y = this.params.ry this.group.position.y = this.params.y } /* Outside of the class */ gsap.ticker.add(() => { figure.bounce() // Render the scene renderer.setSize(window.innerWidth, window.innerHeight) renderer.render(scene, camera) })

为了使手臂移动,我们需要做更多的工作。在我们的类构造函数中,让我们为手臂定义一个变量,它将是一个空数组:

class Figure { constructor(params) { this.arms = [] } }

在我们的createArms()方法中,除了我们的代码之外,我们还将将每个手臂组推送到数组中:

createArms() { const height = 0.85 for(let i = 0; i < 2; i ) { /* Other code for creating the arms.. */ // Push to the array this.arms.push(armGroup) } }

现在我们可以将手臂旋转添加到我们的bounce()方法中,确保我们以相反的方向旋转它们:

bounce() { // Rotate the figure this.group.rotation.y = this.params.ry // Bounce up and down this.group.position.y = this.params.y // Move the arms this.arms.forEach((arm, index) => { const m = index % 2 === 0 ? 1 : -1 arm.rotation.z = this.params.armRotation * m }) }

现在我们应该看到我们的小身影在蹦蹦跳跳,就像在蹦床上一样!

包起来

Three.js 还有很多很多,但我们已经看到,只需使用基本的构建块就可以开始构建有趣的东西并不需要太多,有时限制会孕育创造力!如果您有兴趣进一步探索,我推荐以下资源。

资源,