Это финишная черта! Мы узнали, как настроить проект Vue + SCSS в последних двух эпизодах.





и создайте базовый шаблон портфолио. Теперь пришло время интегрировать Three.js в наш проект!

Но… Что такое Three.js?

Three.js — это графический API, созданный с помощью Web GL. Он имеет бесчисленное количество функций, но наиболее популярными являются те, которые касаются 3D-рендеринга. Как я уже говорил ранее, Three.js — это высокоуровневый API для движка WebGL, поэтому, к счастью, вам не нужно быть математическим волшебником, чтобы отобразить куб 10x10.

В этой статье мы создадим интерактивный фон для нашего сайта. Но помните: это всего лишь пример. Не стесняйтесь заниматься своими делами, пока строите его. Не придерживайтесь исключительно того, что я показываю! В конце концов, это вашеличное портфолио.

Установка Three.js

Первое, что нужно сделать, это загрузить необходимые зависимости в наш проект.

npm install three

Временное отключение Vue

На данный момент лучше начать с отключения нашего контейнера Vue. Таким образом, мы будем уверены, что это не повлияет на наш код Three.js. Просто перейдите в свой файл index.html и прокомментируйте свой блок #app.

index.html
<!DOCTYPE html>
<html lang="">
 <head>
   <meta charset="utf-8" />
   <meta http-equiv="X-UA-Compatible" content="IE=edge" />
   <meta name="viewport" content="width=device-width,initial-scale=1.0" />
   <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
   <link rel="stylesheet" href="./styles.css">
   <title><%= htmlWebpackPlugin.options.title %></title>
 </head>
 <body>
   <!-- <div id="app"></div> -->
 </body>
</html>

Структурирование нашего проекта

Это руководство основано на этом фантастическом руководстве. Discover Three.js — идеальный ресурс для всех, кто хочет начать работу с этой библиотекой. Но он лучше всего подходит для ванильных JS-проектов, поэтому я буду изменять его здесь и там, чтобы объяснить, как настроить его с помощью Vue.

Внутри нашей исходной папки мы создадим папку «Мир». Здесь будет размещен весь наш код, связанный с Three.js. Внутри него мы создадим две папки и один файл:

Компоненты. Как и в случае с Vue, мы будем хранить здесь все повторно используемые элементы. Объекты, камеры, сцены… Мы пройдемся по ним позже.

Системы. Здесь мы сохраняем различные системы в нашем 3D-рендеринге. В нашем случае это цикл анимации, изменение размера, средство визуализации и элементы управления.

World.js: вот где происходит волшебство. В файле World.js все различные системы и компоненты соединяются и вводятся в HTML.

В итоге у вас должна получиться примерно такая структура папок:

Теперь пора переходить к делу! К сожалению, создание 3D-сцены требует большого количества шаблонов. Так что нам придется написать несколько строк кода, прежде чем что-то увидеть. Но не волнуйтесь! Помните: терпение — это добродетель! 😎

Создание наших систем

Создание визуализатора

Здесь нечего сказать. Как вы уже догадались, средство визуализации отрисовывает все. Мы будем использовать WebGL.

renderer.js
import { WebGLRenderer } from "three";
 
function createRenderer() {
 const renderer = new WebGLRenderer({ antialias: true });
 
 // Turn on the physically correct lighting model.
 renderer.physicallyCorrectLights = true;
 
 return renderer;
}
 
export { createRenderer };

Создание ресайзера

Изменение размера является ключевым элементом в нашем проекте. Он обрабатывает события изменения размера окна, что позволяет нам сделать наши сцены отзывчивыми!

Resizer.js
const setSize = (container, camera, renderer) => {
   camera.aspect = window.innerWidth / window.innerHeight;
   camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
   renderer.setPixelRatio(window.devicePixelRatio);
 };
  class Resizer {
   constructor(container, camera, renderer) {
     // Set initial size on load.
     setSize(container, camera, renderer);
      window.addEventListener('resize', () => {
       // Set the size again if a resize occurs.
       setSize(container, camera, renderer);
       // Perform any custom actions.
       this.onResize();
     });
   }
    onResize() {}
 }
  export { Resizer };

Создание цикла анимации

Мы будем делать нашу петлю как класс. Цикл анимации — это то, что дает «жизнь» нашей сцене. Это позволяет нам анимировать разные элементы одновременно. У него есть четыре свойства: камера, сцена, средство визуализации и обновляемый массив, куда мы должны помещать элементы, которые хотим анимировать. У нас также есть три метода: start и stop, которые, как ни странно, запускают и останавливают наш цикл, и методы tick. И это очень важно: метод tick будет запускаться один раз для каждого кадра нашей анимации, что обновляет положение наших объектов, позволяя анимировать!

Все это возможно благодаря объекту Clock от Three, который позволяет нам получить текущую дельту: разницу во времени между текущим кадром и последним.

Loop.js
import { Clock } from 'three';
 
const clock = new Clock();
 
 
class Loop {
 constructor(camera, scene, renderer) {
   this.camera = camera;
   this.scene = scene;
   this.renderer = renderer;
   this.updatables = [];
 }
 
 start() {
   this.renderer.setAnimationLoop(() => {
       this.tick();
       // render a frame
       this.renderer.render(this.scene, this.camera);
   });
 }
 
 stop() {
   this.renderer.setAnimationLoop(null);
 }
 
 tick() {
   const delta = clock.getDelta();
   for (const object of this.updatables) {
       object.tick(delta);
   }
 }
}
 
export { Loop }

Создание нашей сцены

Мы будем создавать нашу сцену шаг за шагом, файл за файлом, чтобы сделать наш проект как можно более модульным. Работа с Three.js может быстро запутаться, если вы не будете осторожны. Так что лучше держать наш код в чистоте и порядке. Каждый из этих модулей сможет экспортировать функцию, которая генерирует новый экземпляр чего-либо. Это позволит нам давать им аргументы и иметь несколько случаев, таких как источники света или объекты.

Создание камеры

С помощью камеры мы можем видеть и манипулировать точкой зрения нашей сцены, которую мы хотим, чтобы наши пользователи имели. Мы будем использовать камеру Perspective, наиболее близкую к реальной камере Three.js, без искажений.

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
 //      x y  z
 camera.position.set(0, 0, 10);
 camera.tick = (delta) => {
  
 };
 
 return camera;
}
 
export { createCamera };

Мы можем настроить поле зрения камеры, начальное положение и несколько различных параметров с помощью простых числовых переменных. Легко, верно?

Как вы увидите, все эти компоненты имеют функцию галочки, которая позволяет им при необходимости взаимодействовать с циклом анимации!

Создание источников света

Управление светом ничем не отличается, но здесь у нас есть больше возможностей для выбора: от направленного, полусферического и точечного света. Давайте пока не будем усложнять и импортируем направленный свет. Вы можете думать об этом свете как о солнечном свете: он освещает все из уникальной точки пространства.

lights.js
import {
   DirectionalLight,
   DirectionalLightHelper,
 } from "three";
  function createLights(color) {
   const light = new DirectionalLight(color, 4);
   const lightHelper = new DirectionalLightHelper(light, 0);
   light.position.set(0, 0, 5);
    light.tick = (delta) => {
   
   };
    return { light, lightHelper };
 }
  export { createLights };

Как и в случае с камерой, мы можем установить положение источника света, но на этот раз мы также можем получить в качестве аргумента цвет источника света!

В этом случае мы возвращаем два объекта: правильный свет и необязательный помощник света, который позволяет нам видеть, где находится свет и куда он указывает.

Создание сцены

Сцена — это место, где все будет рендериться. Здесь можно увидеть все и то, что камера покажет пользователю.

scene.js
import { Color, Scene } from 'three';
 
function createScene(color) {
 const scene = new Scene();
 
 scene.background = new Color(color);
 
 return scene;
}
 
export { createScene };

Как и у источников света, у нас есть аргумент цвета, но на этот раз он относится к фоновому цвету сцены.

Убедиться, что все работает правильно

Итак, мы написали много кода. Я думаю, мы должны проверить, все ли работает правильно, на самом деле что-то отрендерив.

Обновление World.js

Теперь пришло время импортировать все, что мы создали, и добавить в наш файл World.js!

World.js
import { createCamera } from "./components/camera.js";
import { createLights } from "./components/lights.js";
import { createScene } from "./components/scene.js";
import { createRenderer } from "./systems/renderer.js";
import { Loop } from "./systems/Loop.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;
let loop;
 
class World {
   constructor(container) {
     // Instances of camera, scene, and renderer
     camera = createCamera();
     scene = createScene("blue");
     renderer = createRenderer();
      // Initialize Loop
     loop = new Loop(camera, scene, renderer);
      container.append(renderer.domElement);
      // Light Instance, with optional light helper
     const { light, lightHelper } = createLights("white");
      loop.updatables.push(light);
      scene.add(light);
 
     const resizer = new Resizer(container, camera, renderer);
      resizer.onResize = () => {
      this.render();
     };
 
    }
    render() {
     // Draw a single frame
     renderer.render(scene, camera);
   }
    // Animation handlers
   start() {
     loop.start();
   }
    stop() {
     loop.stop();
   }
 }
  export { World };

Наш мировой класс принимает параметр «контейнер». Это элемент HTML, в котором будет отображаться наш файл Three.js.

Обновление нашего HTML и main.js

Теперь в наш index.html мы должны добавить наш контейнер, куда будет вставляться холст.

index.html
<div id="scene-container">
     <!-- Three.js canvas will be inserted here -->
</div>

И, наконец, нам нужно вызвать наш класс World в нашем файле main.js, а также инициализировать наш цикл анимации с помощью метода start.

main.js
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { World } from "./World/World.js";
 
function main() {
 // Get a reference to the container element
 const container = document.querySelector("#scene-container");
 
 // Create an instance of the World app
 const world = new World(container);
 
 // Start the loop (produce a stream of frames)
 world.start();
}
 
main();
 
createApp(App).use(router).mount("#app");

И если все пойдет хорошо, вы должны увидеть что-то вроде этого:

Хорошо, не буду врать: это действительно выглядит довольно разочаровывающим. Но это работает! Как вы могли заметить, я объявил сцену синим фоном в нашем World.js. И эта большая синяя капля — реальная рендеримая 3D-сцена с циклом анимации, работающим кадр за кадром! Это настоящий подвиг.

Что я рекомендую сделать сейчас, так это обновить стили контейнера, чтобы они были полноэкранными и избегали переполнения. Обратите внимание, что для этого вам нужно будет создать специальный файл CSS в общей папке. Что-то вроде этого должно помочь:

#scene-container {
   position: fixed;
   width: 100%;
   height: 100%;
   left: 0;
   top: 0;
}

Наш первый рендер!

Хорошо! Теперь, когда весь шаблонный код работает, пришло время рендерить объект. Мы создадим движущуюся плоскость, похожую на местность, где пользователь сможет вращать камеру.

Рендеринг самолета

Возможно, вы помните, что я создал пустую папку «objects» внутри папки наших компонентов. Здесь будут храниться все ваши 3D-фигуры. В этом посте я покажу вам, как сделать самолет, но если вы когда-нибудь захотите погрузиться глубже и отрисовать разные фигуры, вы должны сделать их здесь.

terrain.js
import {
 PlaneBufferGeometry,
 MeshStandardMaterial,
 Mesh,
 TextureLoader,
} from "three";
 
export default function createTerrain(props) {
 const loader = new TextureLoader();
 const height = loader.load("textures/height.png");
 //                                        w    h 
 const geometry = new PlaneBufferGeometry(150, 150, 64, 64);
 
 const material = new MeshStandardMaterial({
   color: props.color,
 });
 
 
 
 const plane = new Mesh(geometry, material);
 plane.position.set(0, 0, 0);
 plane.rotation.x -= Math.PI * 0.35;
 
 let frame = 0;
 plane.tick = (delta) => {
 
 };
 
 return plane;
}

Для рендеринга 3D-объекта нам понадобится довольно много всего. Но это три самых важных:

Геометрия: это все вершины, из которых состоит наш объект. Это определит его форму. Three.js имеет различные типы геометрии для кубов, сфер и многого другого. Мы будем использовать «плоскую» геометрию для нашего проекта. Мы можем изменить размер нашей геометрии, изменив числа, переданные элементу.

Материал: это то, что придает нашему объекту поверхность. Опять же, у нас есть несколько разных стилей материалов, но давайте не будем усложнять и воспользуемся «Стандартным материалом». Это довольно хороший вариант, потому что он может реагировать на свет. Он принимает объект в качестве параметра с несколькими вариантами настройки. Здесь мы передадим переменную цвета, как и для фона сцены.

Сетка – наиболее часто используемый способ визуализации трехмерных объектов на компьютере. Сетка состоит из геометрии и материала, и это наш реальный 3D-объект.

Мы будем вращать нашу сетку; таким образом, его будет легче заметить на сцене.

Мы должны добавить ландшафт в файл World.js точно так же, как мы сделали это со всеми остальными элементами. Не забудьте добавить его в обновляемую петлю и сцену!

World.js
   ...
   let terrain = createTerrain({
     color: color,
   });
 
   loop.updatables.push(light);
   loop.updatables.push(terrain);
 
   scene.add(light, terrain);
   ...

Вы также можете создать некоторые глобальные цветовые переменные, чтобы упростить переключение палитры. Я выбрал несколько зеленых оттенков, чтобы сохранить стиль Vue.

После обновления вы должны увидеть визуализацию вашего самолета! На первый взгляд он может не выглядеть трехмерным, но попробуйте изменить вращение плоскости, и вы увидите, что это трехмерный объект.

Приправляя нашу плоскую геометрию

Итак, вы только что визуализировали свой первый 3D-объект. Отличная работа! Но это немного мягко. Вы даже не заметите потрясающего 3D. Мы дадим ему карту смещения и анимацию, чтобы он выглядел как воксельный инопланетный мир.

Обновление World.js

Мы создадим массив случайных значений, которые будут определять, как будет двигаться каждая вершина в нашей плоскости.

World.js
   // Random values for terrain vertices
   const randomVals = [];
 
   for (let i = 0; i < 12675; i++) {
     randomVals.push(Math.random() - 0.5);
   }
 
   // Terrain Instance
   let terrain = createTerrain({
     color: color,
     randVertexArr: randomVals,
   });

Как вы видели в объявлении нашей плоскости, наша функция принимает аргумент props, который является объектом. Это означает, что мы можем передать столько мнений, сколько захотим! Мы передадим массив значений, чтобы мы могли использовать его для управления нашей геометрией.

Добавляем текстуру к нашей плоскости

Теперь давайте сделаем нашу плоскость менее плоской. Во-первых, мы должны обновить наш «Материал сетки». Мы добавим шкалу смещения и карту. Карта смещения представляет собой текстуру в градациях серого, которую Three.js использует в качестве ориентира и поднимает каждую вершину в зависимости от того, насколько ярким является этот пиксель на изображении. Масштаб — это просто множитель; чем он больше, тем более выраженным будет смещение. При желании мы также можем включить плоское затенение, придав нашей местности стиль ретро-освещения.

terrain.js 
 const loader = new TextureLoader();
 const height = loader.load("textures/height.png");
 ...
 const material = new MeshStandardMaterial({
   color: props.color,
   flatShading: true,
   displacementMap: height,
   displacementScale: 5,
 });

Совет: сохраняйте изображения текстур в общедоступной папке! Three.js не распознает их в исходной папке.

Вы уже должны видеть свою местность!

Анимация местности

Хорошо, эта часть немного сложна. Мы будем использовать случайные числа и начальные массивы позиций вершин, чтобы перемещать все наши вершины по оси y. Затем мы будем анимировать его с помощью косинусоидальных волн, чтобы сделать контролируемое движение. Я объясню код с некоторыми комментариями:

World.js
   // Random values for terrain vertices
   // We could do this on the terrain.js file,
   // but if we want to have a single random
   // number array for more than one terrain
   // instance, then we would be in trouble.
   const randomVals = [];
   for (let i = 0; i < 12675; i++) {
     randomVals.push(Math.random() - 0.5);
   }
 
   // Terrain Instance
   let terrain = createTerrain({
     color: color,
     randVertexArr: randomVals,
   });
terrain.js
const plane = new Mesh(geometry, material);
 plane.position.set(0, 0, 0);
 plane.rotation.x -= Math.PI * 0.35;
  // Storing our original vertices position on a new attribute
 plane.geometry.attributes.position.originalPosition =
   plane.geometry.attributes.position.array;
 // Utilizing our random number array
 const { array } = plane.geometry.attributes.position;
 for (let i = 0; i < array.length; i++) {
   props.randVertexArr.push(Math.random());
 }
 
 plane.geometry.attributes.position.randomValues = props.randVertexArr;
 
 let frame = 0;
 plane.tick = (delta) => {
   frame += 0.01;
   // Destructuring of the random values, the original position and the current vertex position
   const { array, originalPosition, randomValues } =  plane.geometry.attributes.position;
 
   // Animation for loop
   // In our vertex array, we have 3 coordinates: x, y and z. We are
   // going to animate on ONE coordinate only: the z coordinate.
   // This means we have to omit the x and y coords, hence the i+=3 in our loop.
   // Also, to access that y coordinate on each vertex, we have to add 2 to our
   // current i each time.
   for (let i = 0; i < array.length; i += 3) {
     // Accessing the z coord
     array[i + 2] =
       // Try switching these numbers up, or using sine instead of cosine, see how the animation changes.
       originalPosition[i + 2] + Math.cos(frame + randomValues[i + 2]) * 0.002;
   }
   plane.geometry.attributes.position.needsUpdate = true;
 };

И вуаля! Ваша местность движется!

Как видите, это довольно тонкий эффект. Это более заметно в больших размерах. В любом случае, вы можете изменить переменные цикла анимации ландшафта, чтобы иметь более существенное влияние.

Дополнительные возможности

Хорошо! Мы сделали много в этом посте. Но мы все еще можем немного подправить его и сделать нашу сцену еще лучше! У меня есть еще две функции, которые вы можете попробовать:

Фоновый туман

Попытка скрыть конец нашего самолета — неплохая идея. Мы могли бы растянуть его и сделать больше, но это не лучшее решение. Во-первых, потому что это также растянет нашу карту высот, что сделает эффект смещения мягче и его будет труднее заметить (хотя, возможно, вам это нравится!), а во-вторых, его будет сложнее рендерить на слабых машинах.

К счастью, Three.js снова приходит на помощь. Мы можем добавить в сцену туман всего несколькими новыми строками кода. Вы также можете выбрать цвет и интенсивность тумана.

scene.js
import { Color, Scene, Fog } from "three";
 
function createScene(color) {
 const scene = new Scene();
 
 scene.background = new Color(color);
 
   scene.fog = new Fog(color, 50, 90);
 
 return scene;
}
 
export { createScene };

Управление вращением и орбитой

Еще один отличный готовый инструмент — «Управление орбитой»! Это позволяет нам легко поворачивать и панорамировать камеру. Он также поставляется с функцией автоматического поворота, которую мы будем реализовывать. Сначала мы создадим новый файлcontrols.js в нашей системной папке.

controls.js
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { MathUtils } from "three";
 
function createControls(camera, canvas) {
 const controls = new OrbitControls(camera, canvas);
 
 // Enable controls?
 controls.enabled = true;
 controls.autoRotate = true;
 controls.autoRotateSpeed = 0.2;
 
 // Control limits
 // It's recommended to set some control boundaries
 // to prevent the user from clipping with the objects.
 
 // Y axis
 controls.minPolarAngle = MathUtils.degToRad(40); // default
 controls.maxPolarAngle = MathUtils.degToRad(75);
 
 // X axis
 //   controls.minAzimuthAngle = ...
 //   controls.maxAzimuthAngle = ...
 
 // Smooth camera:
 // Remember to add to loop updatables to work.
 controls.enableDamping = true;
 controls.enableZoom = false;
 controls.enablePan = false;
 
 controls.tick = () => controls.update();
 
 return controls;
}
 
export { createControls };

Затем пришло время добавить его в наш World.js, как и любой другой элемент нашего проекта. Не забудьте добавить его в обновляемые циклы, если хотите включить демпфирование.

Вы также захотите настроить камеру и положение источника света, а также начальное вращение ландшафта, если хотите получить лучший угол обзора.

Я переключил свои значения на эти значения для этого скриншота:

Lights: light.position.set(0, 30, 30);
Camera: camera.position.set(0, 10, 30);
Terrain: plane.rotation.x -= Math.PI * 0.5;

Включение Vue и сохранение вращения мыши

Последняя часть проекта! Теперь нам нужно снова включить Vue. Просто раскомментируйте раздел #app в HTML-коде, и все… Верно? Ну и да, и нет. Потому что, несмотря на то, что наш 3D-фон по-прежнему работает идеально, элементы Vue поверх нашего фона будут «воровать» все события щелчка мыши, и поэтому элементы управления орбитой не будут работать.

Решение, которое я придумал, состоит в том, чтобы отключить события щелчка там, где это необходимо. И это звучит как рутинная работа, но это не так! С простым миксином SCSS у нас все будет хорошо:

mixins.scss
@mixin child-events-on($debug: false) {
   & > * {
       @if $debug {
           background: red;
       }
       pointer-events: all;
   }
 
   @content;
}

Этот миксин активирует все события-указатели только для дочерних элементов контейнера. Вы можете включить режим отладки. Это даст красный фон всем элементам с включенными событиями указателя.

Теперь мы глобально отключаем события указателя в #app и можем включить их в нужных нам контейнерах, включив миксин: например, наш nav. В итоге ваш App.vue должен выглядеть так:

App.vue
<style lang="scss">
#app {
 position: relative;
 padding: 0px auto;
 padding: 0px 2vw;
 z-index: 2;
 pointer-events: none;
 @include desktop {
   padding: 0px 10vw;
 }
}
 
#nav {
 padding: 30px;
 text-align: center;
 @include child-events-on();
 a {
   font-weight: bold;
   color: $text-color;
 
   &.router-link-exact-active {
     color: $active-link;
   }
 }
}
</style>

Совет: не забудьте обновить z-индекс #app и положение, чтобы оно было поверх фона.

И, наконец, наше 3D Портфолио наконец-то готово!

Заключение и следующие шаги

Какая поездка! Мы создали веб-портфолио и сделали его уникальным, добавив случайно сгенерированную анимацию для нашего 3D-фона. Мы также улучшили наш опыт разработки, внедрив Vue, который помогает нам сделать наш код модульным и чистым, и SCSS, который сэкономит нам массу времени при написании нашего CSS.

Теперь пришло время добавить немного индивидуальности! Я намеренно сделал этот пример довольно мягким. Если вы хотите, чтобы ваше портфолио выделялось, вам нужно придумать что-то более уникальное. Возможно, изменить шрифт и цветовую палитру. Или вы можете изменить текстуру карты высот и силу анимации, чтобы передать другой эффект. Или просто прочесать всю местность и попробовать что-то совершенно новое. Вы можете проверить мое собственное портфолио, чтобы увидеть совершенно другой подход к практически той же идее. В любом случае, лучше всего начать с Документации Three.js и домашней страницы для вдохновения.

Если вы хотите поработать с этим примером проекта, вот репозиторий. Или посмотрите мой Vue + Three.js starter, чтобы сделать что-то другое.

Не забудьте также развернуть свое портфолио! Чтобы другие могли увидеть ваши удивительные 3D-сцены 😎

Ресурсы, использованные в этой серии, и рекомендуемая литература