В наш мир постоянно приходят новые технологии. И вот дождались, 3D графика начинает свое победное шествие в WEB, и это затрагивает в первую очередь разработчиков. Не привычно имея дело с разметкой и DOM, “трехмерно” думать. Но как говорил некий Белый кролик в культовом произведении:

Вашему вниманию представлен адаптированный перевод статьи How to Build a Color Customizer App for a 3D Model with Three.js от Kyle Wetton, который написал цикл статей по трехмерной графике в web.

Из этой статьи вы узнаете, как создать полноценное приложение с использованием Three.js для подбора цвета отдельных частей трехмерной модели стула.

Полноценная демо-версия нашего приложения доступна по ссылке: demo, а ее код по этой .

Краткое введение

Приложение, с разработкой которого мы познакомимся в этой статье, построено на примере функционала Vans shoe customizer и использует удивительную JavaScript библиотеку для работы 3D графикой Three.js.

Материалы этой статьи излагаются из предположения, что вы уже в достаточной степени знакомы с основами HTML, CSS и JavaScript.

Цель статьи — мое желание прежде всего научить вас чему-то новому и полезному, а не заставлять вас копировать/вставлять части кода, относящиеся к нашему уроку. CSS в этом проекте используется для стилизации нашего приложения и ориентирован в большей степени на работу с пользовательским интерфейсом. При этом каждый раз, когда мы будем использовать некоторую HTML разметку то, я буду сразу пояснять по тексту, что с ним делают стили CSS. И так давайте начнем!

Часть 1: 3D модель

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

Это не учебник по 3D-моделированию, но я все таки хочу рассказать вам, как подготовить нашу модель, используя Blender. Возможно вы захотите создать что-то свое или изменить любую бесплатную модель, которую вы сами найдете в Интернете. Тем не менее, ознакомимся с информацией о том, как создавалась наша 3D модель стула.

3D-модель для демо-версии нашего приложения уже размещена и подключена к JavaScript, и поэтому не беспокойтесь о ее загрузке или необходимости делать что-либо вообще, если только не хотите углубляться в основы работы с Blender, для создания своей собственной модели.

Масштаб

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

Разделение на слои и соглашение по их именованию

Эта часть важна, так как каждый элемент нашей модели, цвет которой мы хотим настраивать независимо, должен быть выделен в отдельный объект в 3D-сцене, а так же получить уникальное имя. Таким образом у нас имеется спинка, основание, подушки, ножки и подлокотники. Обратите внимание, что, если вы выделите три элемента то, все они будут называются подлокотниками и Blender назовет supports, supports.001, supports.002. Это не имеет значения, потому что в коде JavaScript мы будем использовать инструкцию includes("supports") для поиска всех объектов, которые содержат строку supports.

Расположение

Модель должна быть размещена в центре, формируемой 3D-сцены, ножками на полу. В идеале он должен быть ориентирован в нужном направлении, но при желании можно легко его повернуть или переместить с помощью JavaScript.

Настройки для экспорта

Для экспорта мы будим использовать опцию Blender Smart UV unwrap. Не вдаваясь в технические подробности, это процесс создания набора 2D-координат для граней модели, которые мы можем затем использовать для наложения текстуры на модель. То есть оно позволяет по сути равномерно развернуть все части нашей модели, не растягивая полигоны разными странными способами.

Далее вы должны убедиться в том, чтобы выбрали все объекты модели.

Формат файла

Очевидно, что Three.js поддерживает несколько форматов объектных файлов 3D, однако наиболее рекомендуемым для использования является glTF (.glb). Blender поддерживает этот формат в качестве опции экспорта по умолчанию, поэтому не стоит беспокоиться об излишних трудностях с переводами в различные форматы.

Часть 2: Настройка среды выполнения

Для дальнейшей работы с примерами из этой статьи используйте этот pen или создайте свой собственный, предварительно скопировав из него стили CSS. Этот пустой pen содержит базовые стили CSS, которые мы будем далее использовать в этой статье.

Вы также может взять из этого pen базовый код HTML, который далее будет использоваться в качестве каркаса нашего приложения. Он содержит метатеги для поддержки его адаптивности, а также в нем уже подключены шрифты Google.

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







 

Теперь давайте поместим в наш код элемент canvas. В этом элементе будет визуализироваться весь наш трехмерный мир, в котором, в свою очередь, находится наша модель стула. Весь остальной HTML код, отвечающий за создание элементов UI будет помещен вокруг него. Поместите canvas внизу HTML документа, выше тегов с зависимостями.

Отлично, теперь мы можем создать новую сцену Scene с помощью Three.js.

Трехмерной сценой называют совокупность геометрических моделей 3D-объектов, образующих сложную составную геометрическую модель некоторого трехмерного объема. Размеры 3D сцены определяются кубом минимального размера, содержащего все элементы внутри сцены.

Давайте создадим в нашем JavaScript коде сцену и получим ссылку на нее:

// Инициализируем сцену
const scene = new THREE.Scene();

Получим ссылку на элемент canvas:

const canvas = document.querySelector('#c');

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

И так первым шаг будет создание сцены, вторым — рендера. Давайте добавим следующие инструкции ниже строки кода, где мы получили ссылку на элемент холста canvas.

// Инициализируем рендер
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});

В результате ее выполнения мы создаем новый WebGLRenderer, которому передаем ссылку на наш холст, а также включаем опцию сглаживания antialias, для отображения у нашей 3D-модели более гладких краев.

Добавим рендер в файл документа.

document.body.appendChild(renderer.domElement);

Стили CSS, определяемые для элемента canvas растягивают его до 100% высоты и ширины документа. Поэтому вся страница на вкладке результата pen с кодом примера, теперь стала черной как и весь холст.

Отлично, изображение нашей сцены стало черным, следовательно мы все сделали правильно.

Следующее, что нужно для работы с Three.js — создать цикл, в котором будет обновляться отображение нашей сцены. В его основе мы будем использовать функцию, которая будет запускаться при каждой перерисовке кадров. Назовем эту функцию animate() и добавим ее код ниже остального.

function animate() {
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

animate();

Обратите внимание, в метод render мы передаем ссылку на камеру camera, но пока не определили ее в коде и не настроили. Давайте исправим это.

Когда мы добавим новую камеру в сцену, то по умолчанию она будет помещена в точку с координатами 0,0,0, то есть в то место, где находится наша модель стула. Поэтому определим еще одну переменную cameraFar, в которой определим как далеко от центра сцены наша камера будет перемещаться для того, чтобы мы могли наблюдать наш стул.

var cameraFar = 5;

Теперь давайте в код над функцией для перерисовки animate () {...} добавим инструкции, определяющие в нашей сцене новую камеру.

// добавляем камеру
var camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = cameraFar;
camera.position.x = 0;

Это камера перспективной проекции с заданной высотой поля зрения (проекционной плоскости) равной 50. Отношение сторон проекционной плоскости определяется как window.innerWidth / window.innerHeight, исходя из размеров окна/холста, то есть размеров нашего элемента canvas. Следующие два числа определяют насколько близко или далеко от центра сцены может перемещаться камера при визуализации нашего объекта. В общем случае визуальный результат ее использования зависит от пропорций и размеров ее поля зрения.

Наша сцена все еще закрашена черным цветом, давайте установим цвет ее фона. Сверху, над строкой кода со ссылкой на нашу сцену, добавим переменную BACKGROUND_COLOR, в которую передадим нужный цвет фона.

const BACKGROUND_COLOR = 0xf1f1f1;

Обратите внимание, что для определения цвета мы использовали значение, начинающееся с префикса 0x вместо привычного #. Нужно помнить, что это не обычная строка, с которыми мы имели дело при обработке значений цветов в формате #hex в языке JavaScript. В этом случае используется обычное целое шестнадцатеричное число, которое в Javascript записывается с префиксом 0x.

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

const BACKGROUND_COLOR = 0xf1f1f1;

// Инициализируем сцену
const scene = new THREE.Scene();

// Установим цвет фона
scene.background = new THREE.Color(BACKGROUND_COLOR );
scene.fog = new THREE.Fog(BACKGROUND_COLOR, 20, 100);

Таким образом, у нас есть пустой мир с пустой сценой. Хотя определить что там ничего нет трудно, но так в ней ничего не отбрасывает тени, то будем считать что это так. Теперь пришло время загрузить нашу модель.

Часть 3: Загрузка модели

И так следующий важный шаг — добавим функцию, которая будет загружать нашу модель стула. Ее корректная работа обеспечивается второй зависимостью, которую мы ранее добавили в наш HTML код.

Прежде чем мы сделаем это, давайте создадим новую переменную, в которую поместим ссылку на объект, содержащий нашу загружаемую модель. Для этого добавим следующий код в верхнюю часть нашего JavaScript кода, выше объявления переменной BACKGROUND_COLOR. Также добавим переменную, в которую передадим строку, содержащую путь к загружаемой модели. Я заранее разместил ее файл, как удаленный ресурс, обращаю ваше внимание, что ее размер порядка 1 Мб.

var theModel;
const MODEL_PATH =  "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/chair.glb";

Следующим нашим шагом будет создание loader экземпляра загрузчика файла модели, а затем мы используем его метод load() для загрузки нашей 3D модели. Для этого передадим, ранее объявленной, переменной theModel ссылку на загруженную 3D модель. Установим подходящий для нашего приложения размер (масштаб) загружаемой модели, в нашем примере мы зададим масштабирование ее размеров вдвое большими, чем начальные. Затем изменим начальное положение нашей модели сдвинув ее немного вдоль оси Y на -1. И, наконец, необходимо непосредственно добавить нашу модель в созданную сцену.

Добавим следующий фрагмент кода ниже строк, где мы инициализировали и задавали параметры камеры.

// Инициируем загрузчик объектов 3D модели 
var loader = new THREE.GLTFLoader();

loader.load(MODEL_PATH, function( gltf ){
  theModel = gltf.scene;

// Установить начальный масштаб отображения модели    
  theModel.scale.set( 2 , 2 , 2 );

  // немного сдвинем положение модели вдоль оси y
  theModel.position.y = -1 ;

  // Добавить модель в сцену
  scene.add(theModel);

}, undefined, function( error ){
   console      .error(error)
});

Рассмотрим параметры, передаваемые в метод load() объекта загрузчика модели. Первый параметр это строка, в которой содержится путь к файлу модели, второй — функция обратного вызова, которая запускается после ее успешной загрузки. В третий параметр передаем undefined, этот параметр используется для передачи функции, которая будет выполняться во время загрузки модели. Последний параметр используется для передачи функции, обрабатывающей возникающие при загрузке модели ошибки.

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

Наряду с камерой нашей сцене нужны источники освещения. Фон не подвержен воздействию света и если мы добавим в нашу сцену пол (или поверхность на которой будет располагаться модель), то в начале он будет черным (темным). В свою очередь библиотека Three.js позволяет использовать в сцене сразу нескольких источников света с различными опциями для их настройки. В нашем примере мы собираемся добавить сразу два источника освещения: полусферический HemisphereLight и направленный DirectionalLight.

DirectionalLight представляет собой источник прямого (направленного) освещения — поток параллельных лучей, излучаемых в направлении объекта. HemisphereLight представляет собой равномерное полусферическое освещение, которое подходит для имитации реалистичной окружающей среды.

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

// добавим источники освещения
var hemiLight = new THREE.HemisphereLight( 0xffffff, 0xffffff, 0.61 );
    hemiLight.position.set( 0, 50, 0 );
// добавим полусферическии источник света 
scene.add( hemiLight );

var dirLight = new THREE.DirectionalLight( 0xffffff, 0.54 );
    dirLight.position.set( -8, 12, 8 );
    dirLight.castShadow = true;
    dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024);
// добавим направленный источник света    
    scene.add( dirLight );

И так наш стул стал выглядеть намного привлекательнее! Прежде чем мы продолжим, посмотрим на то, что у нас в результате получилось:

var cameraFar = 5;
var theModel;

const MODEL_PATH =  "https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/chair.glb";

const BACKGROUND_COLOR = 0xf1f1f1;

const scene = new THREE.Scene();

scene.background = new THREE.Color(BACKGROUND_COLOR );
scene.fog = new THREE.Fog(BACKGROUND_COLOR, 20, 100);

const canvas = document.querySelector('#c');


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

document.body.appendChild(renderer.domElement);

var camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = cameraFar;
camera.position.x = 0;

var loader = new THREE.GLTFLoader();

loader.load(MODEL_PATH, function(gltf) {
  theModel = gltf.scene;

  theModel.scale.set(2,2,2);

  theModel.position.y = -1;

  scene.add(theModel);

}, undefined, function(error) {
  console.error(error)
});

var hemiLight = new THREE.HemisphereLight( 0xffffff, 0xffffff, 0.61 );
    hemiLight.position.set( 0, 50, 0 ); 
scene.add( hemiLight );

var dirLight = new THREE.DirectionalLight( 0xffffff, 0.54 );
    dirLight.position.set( -8, 12, 8 );
    dirLight.castShadow = true;
    dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024);  
    scene.add( dirLight );

function animate() {
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

animate();

А вот то, что получилось в результате выполнения этого кода:

Исправим некорректное отображение стула: пикселизацию и растяжение. В соответствии с инструкциями нашего кода, Three.js будет обновлять содержимое холста canvas при изменении его размеров. А так же автоматически подстраивать разрешение отрендеренного изображения не только в зависимости от размеров холста, но и от плотности пикселей, поддерживаемой экраном устройства.

Перейдем к нижней части нашего кода, ниже строки, где мы вызываем функцию animate(), и добавим в это место кода новую функцию. Ее назначение сравнивать размеры холста canvas и окна браузера, а затем возвращать логическое значение в зависимости от того, являются ли их размеры одинаковыми, либо нет. Далее мы будем использовать ее внутри функции animate() для того, чтобы определять, следует ли повторно перерисовывать нашу сцену. Эта функция также будет учитывать плотность пикселей экрана устройства для того, чтобы быть уверенным, что наш холст будет корректно отображать на любых, в том числе и мобильных устройствах.

Добавим эту функцию в самый низ нашего JavaScript кода.

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
  var width = window.innerWidth;
  var height = window.innerHeight;
  var canvasPixelWidth = canvas.width / window.devicePixelRatio;
  var canvasPixelHeight = canvas.height / window.devicePixelRatio;

  const needResize = canvasPixelWidth !== width || canvasPixelHeight !== height;
  if (needResize) {
    
    renderer.setSize(width, height, false);
  }
  return needResize;
}

С ее учетом обновим функцию animate(). И теперь она будет выглядеть следующим образом:

function animate() {
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
  
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
}

Еще мгновение и… Наш стул выглядит уже намного лучше!

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

  • Наш стул оказался повернут задом наперед, и это моя ошибка, которую я допустил до экспорта модели при ее подготовке в Blender. Для того, чтобы исправить это необходимо добавить в наш код инструкции для поворота модели вокруг оси Y.
  • Опорные планки стула черные, а остальные части белые. Это произошло потому, что в исходной модели перед ее экспортом присутствовала информация о материале, который покрывает ее каждую часть, была импортирована в коде. В принципе это не имеет критического значения, так как в нашем приложении мы собираемся добавить функцию, которая позволит нам использовать текстуры и затем накладывать их на отдельные детали стула.

Перейдем к функции загрузчика в то место, где мы установили масштаб модели вдоль каждой из осей (2,2,2). И добавим под ней следующий:

// Установим масштаб модели по умолчанию    
theModel.scale.set(2,2,2);

theModel.rotation.y = Math.PI;

Да, так намного лучше. Еще одна особенность этой библиотеки, насколько я знаю, Three.js не имеет поддержки градусов для представления величин углов. И поэтому для представления угловых величин используются дробные значения от Math.PI (числа пи), которое эквивалентно повороту на 180 градусов. Поэтому, если вы хотите, чтобы что-то было повернуто на угол 45 градусов, то должны использовать выражение Math.PI / 4.

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

И так в результате наших доработок стул приобретает следующий вид.

Отлично. Но нам нужен пол (или поверхность на которой будет стоять наш стул). Так как без него не будет никаких теней, отбрасываемых стулом, что будет выглядеть нереалистично. Для этого добавим в нашу сцену обыкновенную плоскость (двумерная фигуру или трехмерную фигуру без высоты) и будем использовать ее в качестве пол.

// Добавим пол
var floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1);
var floorMaterial = new THREE.MeshPhongMaterial({
  color: 0xff0000,
  shininess: 0
});

var floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -0.5 * Math.PI;
floor.receiveShadow = true;
floor.position.y = -1;
scene.add(floor);

Давайте проанализируем наш код.

Во-первых, мы создали новую геометрическую фигуру плоскость floorGeometry. И хотя в этом руководстве мы далее никаких геометрических примитивов создавать больше не будем, вы можете потренироваться в этом самостоятельно, добавив в сцену и другие фигуры.

Во-вторых, обратите внимание, мы создали новый материал MeshPhongMaterial (этот материал используется для блестящих поверхностей) и задали для его свойств несколько значений: цвет color и блеск бликов shininess. Позже можете самостоятельно ознакомьтесь с другими материалами, использующимися в Three.js. Например, MeshPhongMaterial позволяет настроить световую отражательную способность тела, то есть свойства зеркальных бликов объекта. Существует также MeshStandardMaterial, который поддерживает использование более сложных текстур, такие как металл или ambient occlusion (модель затенения, используемая в трёхмерной графики позволяющая добавить реалистичности изображению за счёт вычисления интенсивности света, доходящего до каждой точки поверхности). Простейший MeshBasicMaterial, который не поддерживает тени, а назначает только цвет для поверхности объекта.

И так мы создали новую переменную floor (пол), содержащую ссылку на объект типа Mesh, в котором объединили его геометрию, созданную на основе полигональной треугольной сетки, а также материал для ее покрытия. Установили небольшой угол наклона пола, включили опцию отображения на нем теней, а также переместили его немного ниже, так же, как мы до этого опускали кресло вдоль оси Y. Затем добавили пол в нашу сцену с помощью инструкции scene.add(floor).

Посмотрим на то, что у нас получилось:

Цвет пола мы пока оставим красным, но где же тени от нашего стула? Есть еще несколько шагов, которые мы должны сделать для этого. Во-первых, используя ссылку, помещенную в renderer передадим значения еще нескольким параметрам:

// Создаем рендер
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});

renderer.shadowMap.enabled = true;
renderer.setPixelRatio(window.devicePixelRatio); 

Сначала мы сделали кое-что совершенно не относящееся к теням: установили соотношение пикселей изображения, формируемого нашим рендером renderer, равное соотношению пикселей устройства, на котором будет запущено наше приложение. Затем включили опцию shadowMap, но тени по-прежнему отсутствуют! Это происходит потому, что материалы, которые у нас используются для отображения стуле, взяты по умолчанию при импорте модели из Blender. И поэтому нам необходимо добавить в наше приложение свои материалы для покрытия деталей стула, которые затем будут отображаться корректно.

И вот как мы это сделаем. Наш объект загрузчика loader включает в себя метод для перебора отдельных объектов, входящих в загруженную нами 3D модель. Добавим следующий код ниже строки theModel = gltf.scene;:

theModel.traverse((o) => {
   if (o.isMesh) {
     o.castShadow = true;
     o.receiveShadow = true;
   }
});

Для каждого объекта нашей 3D модели (ножки, подушки и т. д.) мы собираемся включить опцию отбрасывания тени, а также их отображения. Результат выглядит, возможно, хуже, чем раньше, но, по крайней мере, теперь тень присутствует на полу! Причина этого в том, что в нашей модели все еще присутствуют материалы, которые мы импортировали из Blender вместе с нашей геометрией. Корректное отображение их Three.js не поддерживает, и поэтому надо заменить их на свои, например, на базовый белый PhongMaterial.

Давайте создадим еще один экземпляр класса PhongMaterial и добавим этот код над строкой, где мы инициализируем наш загрузчик loader:

// Инициализируем новый материал
const INITIAL_MTL = new THREE.MeshPhongMaterial( { color: 0xf1f1f1, shininess: 10 } );

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

Мы могли бы просто добавить этот материал к геометрии частей нашего стула и остановиться на этом. Однако при первоначальной загрузке нашего приложения некоторым объектам (частям стула) может понадобиться по умолчанию задать определенный цвет материала или его текстуру. Для этого создадим массив объектов нашей модели (деталей стула) с соответствующими им типами материала. И для начала инициализируем его, используя в качестве значения по умолчанию наш новый тип материала INITIAL_MTL:

// Инициализируем новый материал
const INITIAL_MTL = new THREE.MeshPhongMaterial( { color: 0xf1f1f1, shininess: 10 } );

const INITIAL_MAP = [
  {childID: "back", mtl: INITIAL_MTL},
  {childID: "base", mtl: INITIAL_MTL},
  {childID: "cushions", mtl: INITIAL_MTL},
  {childID: "legs", mtl: INITIAL_MTL},
  {childID: "supports", mtl: INITIAL_MTL},
];

И так, теперь мы будем снова перебирать отдельные объекты нашей 3D-модели (детали стула) для того, чтобы покрыть их материалом с заданными свойствами. Для этого находим наименование объекта (части стула) в массиве INITIAL_MAP, используя значения поля childID, а затем применяем к нему материал, который задан в в соответствующем ему свойстве mtl. Таким образом, значения свойства childID массива будут сопоставлены с определенным типом материала, которые мы будем использовать для покрытия деталей нашего стула по умолчанию. Отметим, что значения свойства childID соответствуют именам, которые мы дали каждому объекту в Blender!

Ниже строки, мы где инициализируем загрузчик модели loader добавим новую функцию, которая в качестве аргументов берет ссылку на загруженную модель, наименование объекта из ее состава (части стула), ссылку на материал, а затем устанавливает этот материал, для покрытия этого объекта (детали). Также функция добавляет объекту (детали) новое свойство nameID, для удобства обращения к нему в дальнейшем.

// функция добавляет в модель текстуры
function initColor(parent, type, mtl) {
  parent.traverse((o) => {
   if (o.isMesh) {
     if (o.name.includes(type)) {
          o.material = mtl;
          o.nameID = type; // устанавливает значение нового свойства для идентификации объекта
       }
   }
 });
}

Теперь внутри кода вызова метода loader.load(), непосредственно перед строкой, где мы добавляем нашу модель в сцену scene.add (theModel);, вызовем нашу функцию initColor() для каждого объекта в массиве INITIAL_MAP:

// Установим начальные значения для текстур деталей стула
for (let object of INITIAL_MAP) {
  initColor(theModel, object.childID, object.mtl);
}

Наконец, вернемся к поверхности пола в нашей сцене и изменим цвет с красного 0xff0000 на светло-серый 0xeeeeee.

// Поверхность пола
var floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1);
var floorMaterial = new THREE.MeshPhongMaterial({
  color: 0xeeeeee, // 
                

Здесь стоит упомянуть, что цвет 0xeeeeee отличается от цвета нашего фона. Я подобрал его оттенок вручную, на глаз, таким, чтобы пол освещенный лучами света не сливался с более светлым фоном. В результате наше приложение будет выглядеть следующим образом:

Часть 4: Добавляем элементы управления

Эта часть нашего руководства будет самой маленькой по объему и простой для понимания благодаря использованию третьей зависимости: OrbitControls.js.

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

Над объявлением функции animate() добавим код, который добавит элементы управления:

// Добавим элементы управления
var controls = new THREE.OrbitControls( camera, renderer.domElement );
controls.maxPolarAngle = Math.PI / 2;
controls.minPolarAngle = Math.PI / 3;
controls.enableDamping = true;
controls.enablePan = false;
controls.dampingFactor = 0.1;
controls.autoRotate = false; // Переключите это, если хотите, чтобы кресло автоматически поворачивалось 
controls.autoRotateSpeed = 0.2; // 30

Внутри функции анимации animate() cверху добавим следующий код:

controls.update();

И так переменная controls содержит ссылку на новый экземпляр класса OrbitControls с необходимой конфигурацией настроек. Мы установим значения для нескольких параметров, которые, если захотите, можете позже изменять самостоятельно. Это диапазон вертикальных углов от minPolarAngle до maxPolarAngle, в котором камера будет вращаться при движении вокруг кресла. Также мы отключим панорамирование камеры enablePan (вращение камеры вокруг своей оси), чтобы наш стул всегда находился в центре сцены. Включим опцию демпфирования камеры enableDamping, придав ей вес (и соответственно равномерность скорости ее вращения). Опция автоматического вращения камеры отключена, но если вы решите ей воспользоваться, то легко это можете сделать.

Теперь, используя мышь, попробуйте зажать левую кнопку и перетащить наш стул. С тем же эффектом вы можете использовать сенсорный экран вашего телефона!

Часть 5: Изменяем цвета

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

Добавим следующий код ниже элемента canvas:

И так, DIV.controls будет прилипать к нижней части экрана, а DIV.tray расширяется на 100% ширины body (окна браузера). Его дочерний элемент, DIV.tray__slide будет заполнен образцами цвета и станет настолько широким, насколько позволяет ширина окна браузера. Далее мы добавим возможность перемещаться по этой панели инструментов для того, чтобы иметь возможность выбрать нужный цвет.

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

const colors = [
{
    color: '66533C'
},
{
    color: '173A2F'
},
{
    color: '153944'
},
{
    color: '27548D'
},
{
    color: '438AAC'
}  
]

Обратите внимание, что значения цветов не имеют привычного нам префикса # или 0x для представления в hex формате. Кроме того значения цветов содержаться в массиве объектов, поэтому мы легко сможем добавить к ним другие свойства, такие как блики или даже пути к изображениям текстуры.

Давайте создадим панель с образцами цвета из этого массива.

Во-первых, сначала получим ссылку на нашу панель инструментов, и добавим следующую строку в верхнюю часть нашего JavaScript кода:

const TRAY = document.getElementById('js-tray-slide');

В нижней части кода, добавим новую функцию buildColors() и сразу ее вызовем.

// Функция добавляет цвета в наш набор инструментов
function buildColors(colors) {
  for (let [i, color] of colors.entries()) {
    let swatch = document.createElement('div');
    swatch.classList.add('tray__swatch');

      swatch.style.background = "#" + color.color;

    swatch.setAttribute('data-key', i);
    TRAY.append(swatch);
  }
}

buildColors(colors);

И так из данных массива с цветами мы создали набор образцов цвета. Обратите внимание, далее в цикле мы устанавливаем значение атрибуту data-key для каждого элемента DIV, соответствующему образцу цвета в панели выбора цветов. Таким образом каждое значение цвета будет привязано к соответствующему элементу DOM (элементу образца цвета), и далее при его выборе использоваться для указания материала детали стула.

Ниже объявления новой функции buildColors() вставим код, добавляющий обработчик события click элементам DOM, соответствующих образцов цвета:

// Образцы цвета
const swatches = document.querySelectorAll(".tray__swatch");

for (const swatch of swatches) {
  swatch.addEventListener('click', selectSwatch);
}

Обработчик клика мыши вызывает функцию selectSwatch(). Эта функция, создает новый материал типа PhongMaterial, а затем вызывает другую функцию, которая будет перебирать объекты, составляющие 3D модель (детали стула), и при нахождении обновит его свойства (цвет или текстуру).

Под обработчиками событий, которые мы только что добавили, добавьте код функции selectSwatch:

function selectSwatch(e) {
     let color = colors[parseInt(e.target.dataset.key)];
     let new_mtl;

     new_mtl = new THREE.MeshPhongMaterial({
         color: parseInt('0x' + color.color),
         shininess: color.shininess ? color.shininess : 10
     });
    
     setMaterial(theModel, 'legs', new_mtl);
}

Напомним, что эта функция получает значение цвета из атрибутаdata-key, выбранного элемента DOM (образца цвета или текстуры), а затем создает из него новый материал с нужными свойствами (цветом).

Пока наш код не сработает потому, что нам нужно добавить определение функции setMaterial() (смотри последнюю строку функции selectSwatch(), которую мы только что добавили).

Обратите внимание на строку: setMaterial(theModel, 'legs', new_mtl); пока что в функцию мы передаем конкретное наименование детали legs (ножки стула). Позже добавим возможность изменять окраску и других его деталей.

Ниже функции selectSwatch() добавим еще одну функцию setMaterial():

function setMaterial(parent, type, mtl) {
  parent.traverse((o) => {
   if (o.isMesh && o.nameID != null) {
     if (o.nameID == type) {
          o.material = mtl;
       }
   }
 });
}

Реализация этой функции схожа с initColor(), но с некоторыми отличиями. Напомним, что ранее при вызове функции initColor() мы передавали каждому объекту нашей 3D модели значение свойства nameID, которое соответствует его типу детали стула. Функция setMaterial(), используя метод traverse(), осуществляет перебор объектов модели и проверяет у каждого значение свойства nameID, и если оно совпадает с наименованием ее типа type, переданного в качестве аргумента, то изменяет значение его свойства material (тип материала), на переданное в функцию значение mtl.

И так теперь используя образцы цвета (текстуры) мы можем создать новый материал и изменить цвет ножек нашего стула legs.

Часть 6: Выбор деталей стула для изменения их покрытия

И так, теперь мы можем изменять цвет ножек нашего стула. В этой части нашего руководства давайте добавим в наше приложение возможность выбирать определенную деталь стула, у которой в соответствии с выбранным образцом (цвета или текстуры) будем изменять материал ее окраски. Включите следующий HTML код ниже открывающего тега body, а использующиеся для его стилизации стили CSS мы рассмотрим ниже.


Этот HTML код добавляет в наше приложение разметку для панели, которая содержит набор кнопок с индивидуальными иконками. Контейнер с кнопками DIV.options прилипает к левому краю экрана.

Каждый элемент DIV.option (кнопка) представляет собой белый квадрат, у которого граница окрашивается красным цветом, когда к нему добавлен класс --is-active (образец света выбран). Его тег содержит атрибут data-option со значением, которое соответствует определенному типу nameID детали стула, по которому мы можем его идентифицировать среди других кнопок. И наконец, к элементу image применяется CSS правило pointer-events:none. Поэтому при клике по иконке будет срабатывать обработчик события у родителя, даже если вы щелкнете по изображению иконки. А в нашем случае у элемента DIV.option.

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

var activeOption = 'legs';

Теперь вернёмся к нашей функции selectSwatch() и обновим ее код в место, где задается значение параметра legs, до инициализации переменной activeOption.

setMaterial(theModel, activeOption, new_mtl);

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

Давайте добавим следующий код выше строки const swatches = document.querySelectorAll(".tray__swatch"); и объявления функции selectSwatch().

// выбор образца расцветки
const options = document.querySelectorAll(".option");

for (const option of options) {
  option.addEventListener('click',selectOption);
}

function selectOption(e) {
  let option = e.target;
  activeOption = e.target.dataset.option;
  for (const otherOption of options) {
    otherOption.classList.remove('--is-active');
  }
  option.classList.add('--is-active');
}

И так мы добавили функцию selectOption(), которая передает переменной activeOption значение атрибута data-option, выбранного элемента стула, а также переключает у них класс --is-active.

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

const colors = [
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/wood.jpg',
    size: [2,2,2],
    shininess: 60
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/denim.jpg',
    size: [3, 3, 3],
    shininess: 0
},
{
    color: '66533C'
},
{
    color: '173A2F'
},
{
    color: '153944'
},
{
    color: '27548D'
},
{
    color: '438AAC'
}  
]

Теперь два верхних элемента нашего массива содержат ссылки на текстуры. Таким образом, для окраски деталей стула мы можем в качестве материала использовать дерево и “деним” (плотная ткань, которая используется для изготовления мебели). Так же для этих двух элементов массива заданы новые свойства, size (число определяющее плотность заполнения поверхности рисунком текстуры) и shininess (блеск поверхности). Размер size будет влиять на то, как часто будет повторяется рисунок текстуры по поверхности детали стула, то есть чем больше эти числа, тем плотнее рисунок или проще говоря тем большее количество раз он повторяется. Подробнее о этих числах вы можете прочитать по ссылке: UV-преобразование.

И так, для использования возможности наложения текстур на детали стула, нам необходимо обновить код следующих двух функции. Во-первых, давайте следующим образом изменим функцию buildColors():

// Функция создает образцы цвета

function buildColors(colors) {
  for (let [i, color] of colors.entries()) {
    let swatch = document.createElement('div');
    swatch.classList.add('tray__swatch');
    
    if (color.texture) {
      swatch.style.backgroundImage = "url(" + color.texture + ")";   
    } else {
      swatch.style.background = "#" + color.color;
    }

    swatch.setAttribute('data-key', i);
    TRAY.append(swatch);
  }
}

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

Обратите внимание на разрыв между 5-м и 6-м образцом? Последняя партия цветов, которую я предоставлю, сгруппирована в цветовые схемы по 5 цветов. Таким образом, каждая сформированная цветовая схема будет иметь этот небольшой разделитель, что реализовано с помощью CSS и далее эта особенность будет иметь больше смысла в конечном продукте.

Вторая функция, которую мы собираемся обновить, это selectSwatch():

function selectSwatch(e) {
     let color = colors[parseInt(e.target.dataset.key)];
     let new_mtl;

    if (color.texture) {
      
      let txt = new THREE.TextureLoader().load(color.texture);
      
      txt.repeat.set( color.size[0], color.size[1], color.size[2]);
      txt.wrapS = THREE.RepeatWrapping;
      txt.wrapT = THREE.RepeatWrapping;
      
      new_mtl = new THREE.MeshPhongMaterial( {
        map: txt,
        shininess: color.shininess ? color.shininess : 10
      });    
    } 
    else
    {
      new_mtl = new THREE.MeshPhongMaterial({
          color: parseInt('0x' + color.color),
          shininess: color.shininess ? color.shininess : 10
          
        });
    }
    
    setMaterial(theModel, activeOption, new_mtl);
}

Давайте разберемся, что она делает. Теперь она проверят, является ли текстурой материал у выбранного образца покрытия. И если это так, то с помощью метода библиотеки Three.js TextureLoader() создает новый объект класса текстуры. А затем устанавливает следующие значения его свойств: число повторов изображения текстуры в системе координат UVW, используя значения свойства size, а также способ обертывания текстурами детали стула в той же системе координат.

По умолчанию текстуры в three.js не повторяются. Чтобы установить, повторяется или нет текстура, есть 2 свойства: wrapS для горизонтального и и wrapT вертикального повторения.

Затем создает новый материл (экземпляр объекта класса) PhongMaterials, которому в качестве значения свойства map передается ссылка на объект текстуры txt, а также значение для свойства shininess, то есть радиус блика.

Соответственно в случае если выбранный образец не является текстурой, то функция selectSwatch() будет работать также как и ранее. Обратите внимание, что в этом случае вы просто устанавливаете значение радиуса блика у свойства shininess.

Внимание: возможно при выборе образцов покрытия с текстурами, поверхность стула возможно станет черной. Проверьте наличие ошибок в консоли браузера. Вы получили cross domain CORS ошибки загрузки файлов? Это ошибка вызвана использованием CodePen, и я уже приложил все усилия, чтобы исправить ее. Эти ресурсы размещаются непосредственно в CodePen в расширенном режиме использования PRO, поэтому приходится использовать различные технические ухищрения для их исправления. Очевидно, что лучшим вариантом является не запрашивать URL-адреса изображений напрямую, в противном случае я рекомендую зарегистрироваться в Cloudinary и использовать его в бесплатном режиме, поместив в него свои текстуры.

Вот pen с корректно работающими текстурами:

Часть 7. Последние штрихи

У меня, как и вероятно у вас, были проекты, в которых присутствовала некая «большая кнопка» (или любой другой элемент UI), которая просто “умоляла” клиента кликнуть по ней, отчаянно источая искушение хотя бы даже просто навести на нее курсор. Однако все эти старания ни к чему не приводили, никто из клиентов и их коллег не мог догадаться, что по ней необходимо кликнуть, что, в конечном итоге, приводило к получению самых нелестных отзывов о вашей работе.

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


Drag to rotate 360°

С помощью CSS размещаем элемент с приглашением к действиям над стулом, и эта хорошая «большая красивая кнопка» предлагает пользователю, используя мышь, начать перетаскивать изображение для того, чтобы наш стул начал вращаться вокруг свой оси.

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

Во-первых, добавим переменную loaded в начало нашего JavaScript кода и установим ее значение false:

var loaded = false;

Вниз JavaScript кода, добавим функцию:

// функция вращения стула после загрузки модели
let initRotate = 0;

function initialRotation() {
  initRotate++;
  if (initRotate 
                

Код этой функции поворачивает модель на 360 градусов в диапазоне углов, поделенном на 120 кадров (длительность поворота около 2 секунд при угловой скоростью 60 кадров в секунду). Мы будем запускать ее в функции animate(), ее код будет запущен в течение 120 кадров, и как только ее выполнение будет завершено (модель обернется вокруг своей оси на 360 градусов), в переменную loaded будет передано значение true. Вот как будет выглядеть теперь функция animate():

function animate() {

  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
  
  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
  
  if (theModel != null && loaded == false) {
    initialRotation();
  }
}

animate();

В коде функции мы проверяем, чтобы значение переменной theModel не была равным null, а значение loaded не равно false. Если эти условия выполняются, то запускаемая функция initialRotation() выполняется в течение 120 кадров, после чего выполняется инструкция load = true, и наша функция animate() прекращает свою работу.

После загрузки модели у нас должен отображаться красивый вращающийся стул. Когда вращение стула останавливается, самое время убрать наш призыв к действию.

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

В верхней части JavaScript кода получим на него ссылку:

const DRAG_NOTICE = document.getElementById('js-drag-notice');

и снова обновим свою функцию анимации animate следующим образом:

if (theModel != null && loaded == false) {
    initialRotation();
    DRAG_NOTICE.classList.add('start');
}

Добавим еще несколько цветов и обновим наш массив цветов.

const colors = [
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/wood_.jpg',
    size: [2,2,2],
    shininess: 60
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/fabric_.jpg',
    size: [4, 4, 4],
    shininess: 0
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/pattern_.jpg',
    size: [8, 8, 8],
    shininess: 10
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/denim_.jpg',
    size: [3, 3, 3],
    shininess: 0
},
{
    texture: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/quilt_.jpg',
    size: [6, 6, 6],
    shininess: 0
},
{
    color: '131417'  
},
{
    color: '374047'  
},
{
    color: '5f6e78'  
},
{
    color: '7f8a93'  
},
{
    color: '97a1a7'  
},
{
    color: 'acb4b9'  
},
{
    color: 'DF9998',
},
{
    color: '7C6862'
},
{
    color: 'A3AB84'
},
{
    color: 'D6CCB1'
},
{
    color: 'F8D5C4'
},
{
    color: 'A3AE99'
},
{
    color: 'EFF2F2'
},
{
    color: 'B0C5C1'
},
{
    color: '8B8C8C'
},
{
    color: '565F59'
},
{
    color: 'CB304A'
},
{
    color: 'FED7C8'
},
{
    color: 'C7BDBD'
},
{
    color: '3DCBBE'
},
{
    color: '264B4F'
},
{
    color: '389389'
},
{
    color: '85BEAE'
},
{
    color: 'F2DABA'
},
{
    color: 'F2A97F'
},
{
    color: 'D85F52'
},
{
    color: 'D92E37'
},
{
    color: 'FC9736'
},
{
    color: 'F7BD69'
},
{
    color: 'A4D09C'
},
{
    color: '4C8A67'
},
{
    color: '25608A'
},
{
    color: '75C8C6'
},
{
    color: 'F5E4B7'
},
{
    color: 'E69041'
},
{
    color: 'E56013'
},
{
    color: '11101D'
},
{
    color: '630609'
},
{
    color: 'C9240E'
},
{
    color: 'EC4B17'
},
{
    color: '281A1C'
},
{
    color: '4F556F'
},
{
    color: '64739B'
},
{
    color: 'CDBAC7'
},
{
    color: '946F43'
},
{
    color: '66533C'
},
{
    color: '173A2F'
},
{
    color: '153944'
},
{
    color: '27548D'
},
{
    color: '438AAC'
}
]

Отлично! В нижнюю часть JavaScript кода, добавим еще одну функцию, которая позволит пользователю перетаскивать панель образцов по окну нашего приложения с помощью мыши или касанием. Я не буду слишком углубляться в то, как это работает, можете сами попытаться разобраться в этом:

var slider = document.getElementById('js-tray'), 
    sliderItems = document.getElementById('js-tray-slide'), 
    difference;

function slide(wrapper, items) {
  var posX1 = 0,
      posX2 = 0,
      posInitial,
      threshold = 20,
      posFinal,
      slides = items.getElementsByClassName('tray__swatch');
  
  // Подключаем события мыши
  items.onmousedown = dragStart;
  
  // Подключаем события Touch касания к экрану
  items.addEventListener('touchstart', dragStart);
  items.addEventListener('touchend', dragEnd);
  items.addEventListener('touchmove', dragAction);


  function dragStart (e) {
     e = e || window.event;
     posInitial = items.offsetLeft;
     difference = sliderItems.offsetWidth - slider.offsetWidth;
     difference = difference * -1;
    
    if (e.type == 'touchstart') {
      posX1 = e.touches[0].clientX;
    } else {
      posX1 = e.clientX;
      document.onmouseup = dragEnd;
      document.onmousemove = dragAction;
    }
  }

  function dragAction (e) {
    e = e || window.event;
    
    if (e.type == 'touchmove') {
      posX2 = posX1 - e.touches[0].clientX;
      posX1 = e.touches[0].clientX;
    } else {
      posX2 = posX1 - e.clientX;
      posX1 = e.clientX;
    }
    
    if (items.offsetLeft - posX2 = difference) {
        items.style.left = (items.offsetLeft - posX2) + "px";
    }
  }
  
  function dragEnd (e) {
    posFinal = items.offsetLeft;
    if (posFinal - posInitial  threshold) {

    } else {
      items.style.left = (posInitial) + "px";
    }

    document.onmouseup = null;
    document.onmousemove = null;
  }

}

slide(slider, sliderItems);

Теперь перейдем к CSS файлу и в правилах для селектора .tray__slider, раскомментируем следующие строки с небольшой анимацией:

/* transform: translateX(-50%);
   animation: wheelin 1s 2s ease-in-out forwards; */

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

Обновим разметку .controls div, чтобы акцентировать внимание пользователя к дополнительному призыву к действию:

Grab to rotate chair. Scroll to zoom. Drag swatches to view more.

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

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

Добавьте следующую разметку под открывающим тегом body :

  

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

Почти готово! Добавить код, который будет удалять оверлей после загрузки модели.

В верхней части нашего JavaScript кода, получим ссылку на элемент оверлея:

const LOADER = document.getElementById('js-loader');

Затем в нашей функции загрузчика loader, после инструкции scene.add(theModel), вставьте следующую строку:

// Удаляем оверлей
LOADER.remove();

Теперь наше приложение загружается за нашим оверлеем div.loading.

И это все! Вот законченный pen для вашего ознакомления.

Вы также можете ознакомиться с demo, размещенным на Codrops.

Спасибо за то, что дошли до конца поста вместе со мной

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

Оставить комментарий