Как стать автором
Обновить

Трехмерные презентации товаров на Three.js для самых маленьких

Время на прочтение10 мин
Количество просмотров27K


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


Приступаем к работе


Первым делом сделаем себе HTML-заготовку. Чтобы не усложнять пример не будем использовать ничего лишнего, никаких сборщиков, препроцессоров и.т.д.


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


<div class='canvas-container'></div>

<script src='https://unpkg.com/three@0.99.0/build/three.min.js'></script>
<script src='https://unpkg.com/three@0.99.0/examples/js/loaders/OBJLoader.js'></script>
<script src='https://unpkg.com/three@0.99.0/examples/js/controls/OrbitControls.js'></script>
<script src='./main.js'></script>

Если у вас в проекте используются NPM и сборщики, то вы можете импортировать это все из пакета three. Собственно, если кто-то не знает, Unpkg берет все из NPM пакетов.


Если вам нужно по-быстрому подключить что-то из какого-то пакета к себе на страницу, но вы не нашли ссылку на CDN – вспомните про Unpkg, скорее всего он вам и нужен.

Основной скрипт начнется с кучи глобальных переменных. Так мы упростим пример.


let SCENE;
let CAMERA;
let RENDERER;
let LOADING_MANAGER;
let IMAGE_LOADER;
let OBJ_LOADER;
let CONTROLS;
let MOUSE;
let RAYCASTER;

let TEXTURE;
let OBJECT;

В Three.js все начинается со сцены, так что инициализируем ее и создаем пару источников света:


function initScene() {
    SCENE = new THREE.Scene();

    initLights();
}

function initLights() {
    const ambient = new THREE.AmbientLight(0xffffff, 0.7);
    SCENE.add(ambient);

    const directionalLight = new THREE.DirectionalLight(0xffffff);
    directionalLight.position.set(0, 1, 1);
    SCENE.add(directionalLight);
}

Источники света бывают разными. Чаще всего в подобных задачах используется ambient – заполняющий свет, и directional – свет в определенном направлении. Еще бывают точечные источники света, но нам они пока не нужны. Цвет свечения делаем белым, чтобы не было никаких искажений.


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

Вторая важная вещь – это камера. Это такая сущность, которая определяет точку, в которой мы находимся, и направление, в котором смотрим.


function initCamera() {
    CAMERA = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 2000);
    CAMERA.position.z = 100;
}

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


Третий объект, который нам нужен – это рендерер. Он отвечает за отрисовку изображения. Его инициализация говорит сама за себя:


function initRenderer() {
    RENDERER = new THREE.WebGLRenderer({ alpha: true });
    RENDERER.setPixelRatio(window.devicePixelRatio);
    RENDERER.setSize(window.innerWidth, window.innerHeight);
}

Загрузчики нужны для того, чтобы загружать данные разных форматов. Здесь вы можете найти длинный список вариантов, но нам понадобятся только два – один для картинок (он идет в комплекте) и один для моделей (мы его подключали в начале).


function initLoaders() {
    LOADING_MANAGER = new THREE.LoadingManager();
    IMAGE_LOADER = new THREE.ImageLoader(LOADING_MANAGER);
    OBJ_LOADER = new THREE.OBJLoader(LOADING_MANAGER);
}

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


function loadModel() {
    OBJ_LOADER.load('./model.obj', (object) => {
        object.scale.x = 0.3;
        object.scale.y = 0.3;
        object.scale.z = 0.3;
        object.rotation.x = -Math.PI / 2;
        object.position.y = -30;

        OBJECT = object;
        SCENE.add(OBJECT);
    });
}

Остается запустить шарманку:


function animate() {
    requestAnimationFrame(animate);
    render();
}

function render() {
    CAMERA.lookAt(SCENE.position);

    RENDERER.render(SCENE, CAMERA);
}

В результате у нас получится просто белая елочка с тенями (модель я взял отсюда).



Раз, два, три, елочка гори! Но без текстур она, конечно, гореть не будет. Да и про шейдеры огня и остальных стихий мы будем говорить как-нибудь в другой раз… Но по крайней мере мы можем видеть, что модель елочки у нас “в телевизоре”.


Перед тем, как перейти к текстурам, полезно добавить стандартный обработчик события изменения размера окна браузера:


function initEventListeners() {
    window.addEventListener('resize', onWindowResize);

    onWindowResize();
}

function onWindowResize() {
    CAMERA.aspect = window.innerWidth / window.innerHeight;
    CAMERA.updateProjectionMatrix();

    RENDERER.setSize(window.innerWidth, window.innerHeight);
}

Добавляем текстуру


Наша модель и картинка-текстура работают по принципу наклеек-переводилок на детских моделях техники. Как мы уже знаем, объекты в контексте WebGL состоят из кучи треугольников. Сами по себе они не имеют никакого цвета. Для каждого треугольника есть такая же треугольная “наклейка” с текстурой, которую нужно на него наклеить. Но если у нас 1000 треугольников, то нам нужно загрузить 1000 картинок-текстур? Разумеется нет. Делается спрайт, такой же, как для иконок в CSS (вы вероятно сталкивались с ними в работе), а в саму модель добавляется информация о том, какие треугольники и где на нем находятся. А дальше Three.js уже самостоятельно разбирается со всем и мы видим готовый результат. На самом деле все немного сложнее, но так должна быть понятна идея.


Ветки елки – это не очень показательный пример. Они все одинаковые. Гораздо лучше структуру такой текстуры будет видно на примере бульбазавра:



Но довольно слов, приступим к действиям. Инициализируем текстуру и загружаем картинку с ней:


function initTexture() {
    TEXTURE = new THREE.Texture();
}

function loadTexture() {
    IMAGE_LOADER.load('./texture.jpg', (image) => {
        TEXTURE.image = image;
        TEXTURE.needsUpdate = true;
    });
}

Теперь нам нужно расширить функцию загрузки модели. Если бы у нас была такая же текстура, как у бульбазавра, все было бы просто. Но у елки текстура покрывает только ветки. Нужно их как-то отделить и применить ее только к ним. Как это сделать? Можно подойти к этому вопросу по-разному. Самое время воспользоваться console.log и посмотреть на саму модель.


Если не знаете, как выделить определенную часть модели – воспользуйтесь console.log. Это обычно самый быстрый способ узнать, чем части отличаются.

Обычно у нас есть два варианта, как поделить модель на части. Первый (хороший) – это когда 3D-художник подписал составные части модели и мы имеем доступ к полям name у них и можем по ним определять, что есть что. В нашем примере такого нет, но зато есть названия материалов. Воспользуемся ими. Для частей модели из материала “Christmas_Tree” будем использовать текстуру:


function loadModel() {
    OBJ_LOADER.load('./model.obj', (object) => {
        object.traverse(function(child) {
            if (child instanceof THREE.Mesh) {
                switch (child.material.name) {
                    case 'Christmas_Tree':
                        child.material.map = TEXTURE;
                        break;
                    // . . .
                }
            }
        });
        // . . .

Таким образом получаем что-то такое:



Для частей из материалов “red” и “pink” (это шарики – елочные игрушки) просто зададим случайный цвет. В таких случаях удобно пользоваться HSL:


switch (child.material.name) {
    case 'Christmas_Tree':
        child.material.map = TEXTURE;
        break;
    case 'red':
         child.material.color.setHSL(Math.random(), 1, 0.5);
         break;
    case 'pink':
        child.material.color.setHSL(Math.random(), 1, 0.5);
        break;
}

Замечание для художников: давайте осмысленные имена всему в моделях. Названия материалов в нашем примере просто ломают мозг. У нас тут красное может быть зеленым. Я не стал их менять, чтобы показать весь абсурд происходящего. Абстрактное название “материал для шариков” было бы более универсальным.

Equirectangular projection


Сложное слово equirectangular projection в переводе на русский – равнопромежуточная проекция. В переводе на бытовой – натянули шарик на прямоугольник. Можете меня цитировать. Все мы в школе видели карту мира – она прямоугольная, но мы понимаем, что если ее немного трансформировать, то получится глобус. Вот это именно оно. Чтобы лучше понять как устроены эти искажения взгляните на картинку:



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


function initWorld() {
    const sphere = new THREE.SphereGeometry(500, 64, 64);
    sphere.scale(-1, 1, 1);

    const texture = new THREE.Texture();

    const material = new THREE.MeshBasicMaterial({
        map: texture
    });

    IMAGE_LOADER.load('./world.jpg', (image) => {
        texture.image = image;
        texture.needsUpdate = true;
    });

    SCENE.add(new THREE.Mesh(sphere, material));
}

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


Итого на данный момент мы имеем что-то такое:



Елочка с цветными шариками довольно мило смотрится.


Orbit controls


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


function initControls() {
    CONTROLS = new THREE.OrbitControls(CAMERA);
    CONTROLS.minPolarAngle = Math.PI * 1 / 4;
    CONTROLS.maxPolarAngle = Math.PI * 3 / 4;
    CONTROLS.update();
}

Есть возможность задать ограничения по углам, на которые можно поворачивать камеру, ограничения на зум и другие опции. Полезно заглянуть в документацию, там много всего интересного.


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


function animate() {
    requestAnimationFrame(animate);
    CONTROLS.update();
    render();
}

Здесь можно отвлечься немного, покрутить елочку в разные стороны…


Raycaster


Raycaster позволет делать следующее: он проводит прямую в пространстве и находит все объекты, с которыми она пересеклась. Это позволяет делать много разных интересных вещей, но в контексте презентаций товаров будут два основных кейса – это реагировать на наведение мыши на что-то и реагировать на клик мышкой по чему-то. Для этого нужно будет проводить линии, перпендикулярные экрану через точку с координатами мыши и искать пересечения. Этим и займемся. Расширяем функцию render, ищем пересечения с шариками и перекрашиваем их:


function render() {
    RAYCASTER.setFromCamera(MOUSE, CAMERA);
    paintHoveredBalls();

    // . . .
}

function paintHoveredBalls() {
    if (OBJECT) {
        const intersects = RAYCASTER.intersectObjects(OBJECT.children);

        for (let i = 0; i < intersects.length; i++) {
            switch (intersects[i].object.material.name) {
                case 'red':
                    intersects[i].object.material.color.set(0x000000);
                    break;
                case 'pink':
                    intersects[i].object.material.color.set(0xffffff);
                    break;
            }
        }
    }
}

Нехитрыми движениями мыши туда-сюда-обратно убеждаемся, что все работает.



Но тут есть одна тонкость – Three.js не умеет плавно менять цвета. Да и в целом эта библиотека не про плавные изменения значений. Здесь самое время подключить какой-нибудь инструмент, который для этого предназначен, например Anime.js.


<script src='https://unpkg.com/animejs@2.2.0/anime.min.js'></script>

Используем эту библиотеку для анимации значений:


switch (intersects[i].object.material.name) {
    case 'red':
        // intersects[i].object.material.color.set(0x000000);
        anime({
            targets: intersects[i].object.material.color,
            r: 0,
            g: 0,
            b: 0,
            easing: 'easeInOutQuad'
        });
        break;
     // . . .
}

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


Символы в ES6+ — это очень мощный инструмент, который помимо прочего позволяет добавлять информацию к объектам из сторонних библиотек, не опасаясь, что это приведет к конфликту имен или сломает логику.

Делаем глобальную константу (по идее стоило бы сделать глобальный объект для всех подобных символов, но у нас простой пример, не будем его усложнять):


const _IS_ANIMATED = Symbol('is animated');

И добавляем проверку в функцию перекрашивания шариков:


if (!intersects[i].object[_IS_ANIMATED]) {
    anime({
        targets: intersects[i].object.material.color,
            r: 0,
            g: 0,
            b: 0,
            easing: 'easeInOutQuad'
    });
    intersects[i].object[_IS_ANIMATED] = true;
 }

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


Всплывающие подсказки


Последняя вещь, которую мы сегодня сделаем – это всплывающие подсказки. Эта задача часто встречается. Для начала нам нужно просто их сверстать.


<div class='popup-3d'>С Новым Годом!</div>

.popup-3d {
    color: #fff;
    font-family: 'Pacifico', cursive;
    font-size: 10rem;
    pointer-events: none;
}

Не забывайте отключать pointer-events если в них нет необходимости.

Остается добавить CSS3DRenderer. Это на самом деле не совсем рендерер, это скорее штука, которая просто добавляет CSS-трансформации к элементам и кажется, что они находятся в общей сцене. Для всплывающих надписей – это как раз то, что нужно. Делаем глобальную переменную CSSRENDERER, инициализируем ее и не забываем вызвать саму функцию render. Все похоже на обычный рендерер:


function initCSSRenderer() {
    CSSRENDERER = new THREE.CSS3DRenderer();
    CSSRENDERER.setSize(window.innerWidth, window.innerHeight);
    CSSRENDERER.domElement.style.position = 'absolute';
    CSSRENDERER.domElement.style.top = 0;
}

function render() {
    CAMERA.lookAt(SCENE.position);

    RENDERER.render(SCENE, CAMERA);
    CSSRENDERER.render(SCENE, CAMERA);
}

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


function initPopups() {
    const popupSource = document.querySelector('.popup-3d');
    const popup = new THREE.CSS3DObject(popupSource);

    popup.position.x = 0;
    popup.position.y = -10;
    popup.position.z = 30;
    popup.scale.x = 0.05;
    popup.scale.y = 0.05;
    popup.scale.z = 0.05;

    console.log(popup);

    SCENE.add(popup);
}

Теперь мы видим надпись “в 3D”. На самом деле она не совсем в 3D, она лежит поверх канваса, но для всплывающих подсказок это не так важно, важен эффект


Остается последний штрих – плавно показывать надпись в определенном диапазоне углов. Снова используем глобальный символ:


const _IS_VISIBLE = Symbol('is visible');

И обновляем состояние всплывающего элемента в зависимости от угла поворота камеры:


function updatePopups() {
    const popupSource = document.querySelector('.popup-3d');
    const angle = CONTROLS.getAzimuthalAngle();

    if (Math.abs(angle) > .9 && popupSource[_IS_VISIBLE]) {
        anime({
            targets: popupSource,
            opacity: 0,
            easing: 'easeInOutQuad'
        });
        popupSource[_IS_VISIBLE] = false;
    } else if (Math.abs(angle) < .9 && !popupSource[_IS_VISIBLE]) {
        anime({
            targets: popupSource,
            opacity: 1,
            easing: 'easeInOutQuad'
        });
        popupSource[_IS_VISIBLE] = true;
    }
}

Все достаточно просто. Теперь надпись плавно появляется и исчезает. Можно добавить автоповорот и наслаждаться результатом.


CONTROLS.autoRotate = true;
CONTROLS.autoRotateSpeed = -1.0;


Заключение


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


P.S.: Полные исходники примера с елочкой доступны на гитхабе.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+17
Комментарии7

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн