Three.js构造蒸汽波场景

在我花了几个月的时间来探索积压的东西之后,终于开始学习 Three.js 🎉。 几周来,我一直在关注@bruno_simon 的 Three.js 旅程课程,这让我大开眼界。 感觉就像它为我打开了一个新的可能性领域,让我可以花时间做更有创意的编码。

在学习课程的过程中,有一刻我觉得我需要自己探索和构建一些东西来应用我学到的东西。

我想到的一个项目是对 Linear 的 2021 发布页面的WebGL 动画进行逆向工程,然后尝试重建它,看看我能从源材料中获得多接近。 自从我去年六月在我的时间线上看到这个场景后,我就有点着迷了。 我非常喜欢这个动画的蒸汽波/超速氛围,我认为参与这个项目的开发人员和设计师做得非常出色👏✨。 最重要的是,这个场景恰好触及了广泛的关键 Three.js 概念,这对于第一个项目来说是完美的!

在这篇博文中,我们将重建这个 vaporwave Three.js 场景的思考过程和步骤,只使用我最近学到的基本结构。 如果你不想等到本文结尾才能看到结果,可以前往 这里进行预览 😛。

我在整篇文章中为该项目的每个关键步骤添加了可编辑的代码片段及其相应的渲染场景(包括注释)。 你将被邀请修改它们并观察一些更改如何影响 Three.js 场景的最终渲染😄。项目的代码可以在这里下载。

1、设置场景

首先,我们需要进行一些初始设置,以获得构建场景所需的一切。 要渲染 Three.js 场景,需要以下关键元素:

  • 一个场景
  • 具有材质和几何体的网格。
  • 相机
  • 渲染器
  • 一些用于调整大小和动画的事件监听器

基本的Three.js场景:

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const canvas = document.querySelector('canvas.webgl');

// Scene
const scene = new THREE.Scene();

// Objects
/**
 * Here I use a Plane Geometry of width 1 and height 2
 * It's also subdivided into 24 square along the width and the height
 * which adds more vertices and edges to play with when we'll build our terrain
 */
const geometry = new THREE.PlaneGeometry(1, 2, 24, 24);
const material = new THREE.MeshBasicMaterial({
  color: 0xffffff,
});

const plane = new THREE.Mesh(geometry, material);

// Here we position our plane flat in front of the camera
plane.rotation.x = -Math.PI * 0.5;
plane.position.y = 0.0;
plane.position.z = 0.15;

scene.add(plane);

// Sizes
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
};

// Camera
const camera = new THREE.PerspectiveCamera(
  // field of view
  75,
  // aspect ratio
  sizes.width / sizes.height,
  // near plane: it's low since we want our mesh to be visible even from very close
  0.01,
  // far plane: how far we're rendering
  20
);

// Position the camera a bit higher on the y axis and a bit further back from the center
camera.position.x = 0;
camera.position.y = 0.06;
camera.position.z = 1.1;

// Controls
// These are custom controls I like using for dev: we can drag/rotate the scene easily
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

// Renderer
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// Event listener to handle screen resize
window.addEventListener('resize', () => {
  // Update sizes
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;

  // Update camera's aspect ratio and projection matrix
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();

  // Update renderer
  renderer.setSize(sizes.width, sizes.height);
  // Note: We set the pixel ratio of the renderer to at most 2
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

// Animate: we call this tick function on every frame
const tick = () => {
  // Update controls
  controls.update();

  // Update the rendered scene
  renderer.render(scene, camera);

  // Call tick again on the next frame
  window.requestAnimationFrame(tick);
};

// Calling tick will initiate the rendering of the scene
tick();

我知道……,感觉有点不知所措。 不过别担心! 让我们花一些时间来分解这些元素中。

首先看如何定义场景:

// Canvas code...

// Scene
const scene = new THREE.Scene();

// Objects code...

首先,我们有场景对象 scene。 这是包含我们将渲染的对象的容器。

接下来是如何定义网格对象:

// Scene code...

// Objects
const geometry = new THREE.PlaneGeometry(1, 2, 24, 24);
const material = new THREE.MeshBasicMaterial({
  color: 0xffffff,
});

const plane = new THREE.Mesh(geometry, material);

// Sizes code...

然后我们定义将添加到场景中的对象。 对于我们的项目,只有一个:只是一架简单的飞机。 我选择从平面开始,因为我们正在处理风景。 当然,还有许多其他可用的几何体,但我们的蒸汽波场景不需要任何其他几何体。

Three.js 对象始终使用 2 个关键元素定义:

  • 几何:我们对象的形状。 这里我们使用 Three.js 的 PlaneGeometry 来表示一个平面。 我故意给它设置了 1 个“单位”的宽度和 2 个“单位”的高度,因为我希望渲染景观的这个平面感觉“长”。 它的宽度和高度也被细分为 24 段,这是为了给我们更多的顶点来玩,让我们用更多的细节来塑造我们的平面。
  • 材质:物体的外观。 在这里,我使用了 MeshBasicMaterial,这是你可以在 Three.js 中使用的最简单的材质。 在这种情况下,我将颜色设置为白色,这样我们的飞机在我们的场景中就会是白色的

通过组合几何体和材料,将获得我们的对象,也称为网格。

下面看如何定义摄像机:

// Sizes code...

// Camera
const camera = new THREE.PerspectiveCamera(
  // field of view
  75,
  // aspect ratio
  sizes.width / sizes.height,
  // near plane: it's low since we want our mesh to be visible even from very close
  0.01,
  // far plane: how far we're rendering
  20
);

// Position the camera a bit higher on the y axis and a bit further back from the center
camera.position.x = 0;
camera.position.y = 0.06;
camera.position.z = 1.1;

// Controls code...

在这里我们定义了相机,一个代表我们在场景中的视角的对象。 我将它放置在靠近地面的位置 camera.position.y = 0.06 并离场景中心 camera.position.z = 1.1 稍远一些,以获得与原始场景相似的视角。

在 Three.js 项目上工作时,描述我们工作空间的轴设置如下:

Axes Helper。 “x”是从中心向左和向右移动事物的水平轴。 “y”是上下移动东西的垂直轴。 “z”是“深度”轴,默认指向观察者

下面看如何定义Three.js渲染器并处理视图缩放:

// Controls code...

// Renderer
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// Event listener to handle screen resize
window.addEventListener('resize', () => {
  // Update sizes
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;

  // Update camera's aspect ratio and projection matrix
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();

  // Update renderer
  renderer.setSize(sizes.width, sizes.height);
  // Note: We set the pixel ratio of the renderer to at most 2
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

// Animate code...

渲染器会将我们的场景渲染/绘制到 HTML 画布上。 它是一个使用之前设置的相机来获取场景快照并将其显示在页面上的工具。 它需要在窗口调整大小时更新,以便无论视口大小如何,场景都可以正确渲染。

接下来定义处理动画的tick函数:

// Renderer and resize handler code...

// Animate: we call this tick function on every frame
const tick = () => {
  // Update controls
  controls.update();

  // Update the rendered scene
  renderer.render(scene, camera);

  // Call tick again on the next frame
  window.requestAnimationFrame(tick);
};

// Calling tick will initiate the rendering of the scene
tick();

tick 函数将处理我们场景中的动画和相机移动。 由于使用了 requestAnimationFrame,它会在每一帧上执行。 现在,它只处理我们的 OrbitControls:一个 Three.js 实用程序,让我们可以使用鼠标来抓取和移动场景,我在构建场景以从任何角度查看它时经常使用它。 稍后我们将使用它来处理与动画相关的任何事情 ✨。

我们现在有了场景的基础:一个平面。 在我们开始使用它之前,我们必须仔细查看 Linear 发布页面的 WebGL 动画并解构场景以了解我们需要做什么才能实现类似的渲染。

2、解构原始场景

在上面,你可以看到我开始做这个项目时写下的注释。 我对实现细节所做的大部分决定都是根据我自己对这个场景的观察做出的,所以下面只是为了说明我的思路:

  • 该平面将需要一个纹理来在其上绘制网格
  • 平面需要有一些位移来塑造两侧的地形
  • 地形非常低多边形,似乎与网格纹理相匹配。 因此,我们的网格中可以有与平面的细分一样多的“正方形”(我数了 24,但这可能是非常错误的 😀)。 因此,无论我们如何塑造地形,网格纹理的交点都将与平面顶点的位置相匹配,从而赋予其独特的蒸汽波外观
  • 表面在某些区域有点发亮,所以我们需要在相机后面放一盏红灯并调整网格的材质
  • 地形向我们(观察者)移动,因此我们将沿 z 轴设置平面位置的动画
  • 现在我们已经分析了我们的场景,我们可以开始构建🤘。

3、材质

首先,让我们让 PlaneGeometry 看起来更像最终渲染。 我们可以从线性场景中看到,地形主要是某种网格。 为了达到这种效果,我们需要做三件事:

  • 绘制网格并在 Figma 等软件上将其导出为 .jpg 或 .png
  • 将此文件作为纹理加载到我们的场景中
  • 将该纹理放在我们的平面上,瞧 ✨ 我们将获得蒸汽波网格效果!

一开始听起来可能很复杂,但是 Three.js 使用 textureLoader 类只需几行代码就可以很容易地做到这一点。

// Instantiate the texture loader
const textureLoader = new THREE.TextureLoader();
// Load a texture from a given path using the texture loader
const gridTexture = textureLoader.load(TEXTURE_PATH);

加载纹理后,我们通过将纹理分配给材质的 normalMap 属性将其应用到平面上,我们得到如下内容:

4、地形

我们现在可以专注于地形。 我们想在平面的每一侧创建一些陡峭的山峰,但要保持平面的中间平坦。 我们该怎么做?

首先,我们需要改变我们的材料。 到目前为止,我们只使用了 MeshBasicMaterial,顾名思义,它是基本的。 我们需要一种更高级的材质,例如 MeshStandardMaterial,它允许我们使用它进行更多操作:

  • 它是基于物理的,这意味着它更逼真并且可以与光相互作用
  • 我们可以编辑不同的顶点,从而改变网格的“形状”。 这是我们现在制作地形所需的属性。

但是,如果你更改材质并刷新预览,可能会注意到场景突然变暗了。 这是因为,与 MeshBasicMaterial 不同,MeshStandardMaterial 需要光线才能显示在屏幕上。

为了解决这个问题,我添加了一个白色的环境光,这是一个简单的光,可以在下面的操场上向各个方向发射。 尝试把这个场景的灯光的代码注释掉,看看效果:

现在我们已经设置了材质,我们需要通过移动网格材质的顶点来塑造地形。 使用 Three.js,我们可以通过提供另一种纹理来做到这一点:置换贴图。 一旦应用于材质的 displacementMap 属性,该纹理将告诉我们的渲染器我们材质的点在哪个高度。

这是我提供给这个场景的置换图(也称为“高度图”)

我们可以像之前导入网格纹理一样导入置换贴图:使用 textureLoader。 最重要的是,Three.js 允许你指定一个 displacementScale:置换贴图影响网格的强度。 我使用了 0.4 的值,我通过简单地调整直到感觉合适为止。

我们现在可以看到场景的地形正在成形✨:

5、动画场景

我们越来越近了! 我们现在有一个包含具有适当纹理的地形的场景。 现在是时候研究一些 Three.js 动画模式来让我们的场景移动了。

6、动画模式和帧率

当我们解构 Linear WebGL 动画时,我们看到地形正在向我们移动。 因此,为了在我们自己的场景中获得这种效果,我们需要沿 z 轴移动网格。 你会看到,其实很简单😄!

我们之前在设置场景时谈到了 tick 功能。 这是在每一帧上被一次又一次调用的函数。 为了使地形移动,我们将在每一帧上沿 z 轴增加网格的位置。

因此,为了使我们的地形移动,我们需要相对于经过的时间增加我们的网格 z 位置,如下所示:

// Renderer and resize handler code...
// Instantiate the Three.js Clock
const clock = new THREE.Clock();

// Animate
const tick = () => {
  // Get the elapsedTime since the scene rendered from the clock
  const elapsedTime = clock.getElapsedTime();

  // Update controls
  controls.update();

  // Increase the position of the plane along the z axis
  // (Multiply by 0.15 here to "slow down" the animation)
  plane.position.z = elapsedTime * 0.15;

  // Render
  renderer.render(scene, camera);

  // Call tick again on the next frame
  window.requestAnimationFrame(tick);
};

8、让场面无穷无尽

你会注意到我们的场景现在有一个问题:飞机向我们移动,但由于它的长度是有限的,几秒钟后我们什么也看不到 😀:

我们必须找到一种方法,让用户觉得这片土地永远存在。 出于明显的原因,我们不能让我们的地形无限大,这是不可能的,但我们可以使用一些技巧!

  • 我们可以添加我们平面的第二个副本,把它放在第一个平面后面,让它也向我们移动
  • 一旦第一个平面经过我们的相机(就在它后面),第二个平面将与第一个平面在过渡开始时的位置相同
  • 我们现在可以将两个平面重置到它们的原始位置,分别是 z=0 和 z=-2,而观众不会注意到。
  • 我们的动画将因此感觉无限。 此外,我们的地形看起来足够有机,以至于我们一直在重复使用同一架飞机并不太明显😄

实现这个效果只需要几行代码(和一些数学):

// Renderer and resize handler code...

const clock = new THREE.Clock();

// Animate
const tick = () => {
  const elapsedTime = clock.getElapsedTime();
  // Update controls
  controls.update();

  /**
   * When the first plane reaches a position of z = 2
   * we reset it to 0, its initial position
   */
  plane.position.z = (elapsedTime * 0.15) % 2;
  /**
   * When the first plane reaches a position of z = 0
   * we reset it to -2, its initial position
   */
  plane2.position.z = ((elapsedTime * 0.15) % 2) - 2;

  // Render
  renderer.render(scene, camera);

  // Call tick again on the next frame
  window.requestAnimationFrame(tick);
};

让我们将这段代码添加到我们的 tick 函数中,看看魔法会发生 ✨:

我们做到了! 🎉我们设法在无限循环中为我们的场景设置动画,并且我们正在慢慢接近 Linear 的原始场景。 但是,还有一些细节需要补充。

9、添加后处理效果

正如你在之前的 playground 中看到的那样,与 Linear 团队提出的相比,我们的地形看起来有点偏离。 一开始我真的不知道那是什么,就好像我们的地形看起来太陡峭了。 但是,在仔细查看原始场景后,我注意到以下内容:

乍一看,我们的纹理好像弄错了,对吧? 它实际上比这更微妙。 相信我,我尝试用 RGB 线重建网格,结果完全是垃圾🤮。

线性 WebGL 场景实际上利用了一些 Three.js 后处理效果。 在此特定情况下,它使用 RGBShift 效果。 或者至少我是这么认为的😄。 这是使我们的场景更接近 Linear 团队获得的结果的唯一效果。 所以我们将继续使用它。

下面,你可以找到我想出的代码,用于在我们的场景中包含 RGBShift 效果:

// Renderer code...

// Post Processing
// Add the effectComposer
const effectComposer = new EffectComposer(renderer);
effectComposer.setSize(sizes.width, sizes.height);
effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

/**
 * Add the render path to the composer
 * This pass will take care of rendering the final scene
 */
const renderPass = new RenderPass(scene, camera);
effectComposer.addPass(renderPass);

/**
 * Add the rgbShift pass to the composer
 * This pass will be responsible for handling the rgbShift effect
 */
const rgbShiftPass = new ShaderPass(RGBShiftShader);
rgbShiftPass.uniforms['amount'].value = 0.0015;

effectComposer.addPass(rgbShiftPass);

// Resize handler code...

// Animate code...
const tick = () => {
  //...

  // Render
  /**
   * We don't need the renderer anymore, since it's taken care of
   * in the render pass of the effect composer
   */
  // renderer.render(scene, camera);
  /**
   * We use the render method of the effect composer instead to
   * render the scene with our post-processing effects
   */
  effectComposer.render();

  // Call tick again on the next frame
  window.requestAnimationFrame(tick);
};

你可以看到这里引入了一些新元素:

  • EffectComposer:管理所有后处理效果以最终产生最终结果的类theRenderPass:负责场景第一次渲染的通道。
  • 我们的 rGBShiftPass:负责应用 RGBShift 效果的后处理通道。
  • 当我第一次应用这种效果时,颜色最终看起来……很不一样:

经过一些调查,我发现在应用某些效果后,Three.js 场景可能会变暗,因为渲染器的输出编码不再起作用。 为了解决这个问题,我们需要添加另一个名为 GammaCorrectionShader 的后处理效果通道,它将充当我们场景的一种颜色校正层。

在下面你会发现我们渲染的场景和我们的后处理效果看起来简直太棒了⚡️。 在其中你可以尝试:

  • 注释掉 gammaCorrectionPass 并查看颜色如何变得有点混乱
  • 调整 rgbShiftPass 的值,使我们的 RGB 偏移更强烈或更不强烈!

10、要有光!

我们现在错过了场景中最重要的方面:光! 原始场景有某种红色光被反射在一些(不是全部)网格正方形上,具有某种拉丝金属效果。 我们如何做到这一点?

我不得不寻找提示来弄清楚在这里做什么。 通过查看网格上的反光方块,我认为应该有两盏灯指向场景的两侧(而不是地板)。 经过一些研究,聚光灯似乎是唯一适合的灯,所以我将它们定义如下:

这相当于以下代码:

// Ambient light code...

// Right Spotlight aiming to the left
const spotlight = new THREE.SpotLight('#d53c3d', 20, 25, Math.PI * 0.1, 0.25);
spotlight.position.set(0.5, 0.75, 2.2);
// Target the spotlight to a specific point to the left of the scene
spotlight.target.position.x = -0.25;
spotlight.target.position.y = 0.25;
spotlight.target.position.z = 0.25;
scene.add(spotlight);
scene.add(spotlight.target);

// Left Spotlight aiming to the right
const spotlight2 = new THREE.SpotLight('#d53c3d', 20, 25, Math.PI * 0.1, 0.25);
spotlight2.position.set(-0.5, 0.75, 2.2);
// Target the spotlight to a specific point to the right side of the scene
spotlight2.target.position.x = 0.25;
spotlight2.target.position.y = 0.25;
spotlight2.target.position.z = 0.25;
scene.add(spotlight2);
scene.add(spotlight2.target);

// Sizes...

现在,我们地形的反射部分怎么样? 当我们之前介绍我们的 MeshStandardMaterial 时,我们提到它是一种基于物理的材质。 这意味着我们可以调整它的属性,使其像真实材质一样与光及其环境交互,例如:

  • 金属度:材料像金属的程度。 0 为非金属,1 为纯金属。
  • 粗糙度:材料的粗糙程度。 0 是光滑的,几乎像镜子一样,1 是漫射的。

然而,在我们的例子中,我们的材料表现不一致:

  • 一些正方形会散射一些光,因此它们会更粗糙且金属感更少
  • 其他一些方块不散射光,因此它们将是纯金属的

为了实现这一点,我们可以设置材质的 metalnessMap 属性:一个纹理来指示网格的部分应该是金属的,而那些不应该是金属的。

通过添加这个 metalnessMap,调整材质的金属度和粗糙度值(我分别选择了 0.96 和 0.5,再次进行了大量调整),最后添加正确的灯光指向我们场景中的正确位置,我们得到了最终结果 漂亮的地方 🎉!

11、结束语

从一个简单的平面几何形状,我们设法仅用几行代码和一些调整就构建了一个时尚、动画、蒸汽波 Three.js 场景 🎉。 我们可以花大量时间尝试进一步调整此场景以改进:

  • 灯光:我没有处理的太好😊
  • 纹理:网格似乎有点太厚了。 也许原始团队根本没有使用纹理而是依赖着色器?
  • 概率表现
  • 添加一些曲目作为背景音乐以配合场景的氛围

但是如果没有原始场景,很难得到完全相同的结果。 整个项目纯粹是通过猜测和应用我在 Three.js 旅程课程中学到的东西来完成的,所以我觉得结果看起来已经很酷了!

我希望你和我一样喜欢这个项目。 我觉得这是一个很棒的第一个项目,可以更多地实践 Three.js 的一些基本概念,例如:

  • 任何与网格有关的东西:纹理、几何形状、材料及其属性
  • 光线和后期处理效果,如果调整得当,可以为场景带来完美的氛围
  • 动画和帧率

而不会陷入教程地狱。 如果你想进一步提高你的 Three.js 技能,我强烈建议你选择一个你喜欢的简单场景并开始对其进行逆向工程/重建,就像我为这个场景所做的那样:你会学到很多东西!


原文 链接:Building a Vaporwave scene with Three.js

BimAnt翻译整理,转载请标明出处