Всевозможные презентации товаров в 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.: Полные исходники примера с елочкой доступны на гитхабе.

Комментарии (7)


  1. AngReload
    21.12.2018 18:38
    +1

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


    1. sfi0zy Автор
      21.12.2018 18:44

      В таких примерах просто плоский код с комментариями быстро превращается в листинги по 300-400 строк и становится совсем не понятным — просто в голове не помещается. Обычно я разбиваю его на маленькие процедуры по смыслу. А про глобальные переменные могу сказать, что обычно они либо таковыми и получаются (в рамках модуля), либо входят в какой-то класс. Но в примере все писать через this как-то совсем некрасиво. Если у вас есть какие-то предложения по тому, как можно организовывать код примеров, чтобы без глобальных переменных и при этом достаточно понятно — буду рад предложениям.


  1. noodles
    21.12.2018 23:45

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

    Подскажите, а это зачем?


    1. sfi0zy Автор
      22.12.2018 10:49

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


      1. noodles
        22.12.2018 12:25

        спасибо


  1. Tyusha
    21.12.2018 23:57

    Иван. Меня очень разочаровывают в three.js тени. Путём долгих-предолгих мучений поняла, что объект не может отбразывать тень сам на себя. Имеется ввиду объект сложной формы. Взаимные тени возникают только от разных объектов. Это большая проблема, т.к. приходится один логический объект делить на выпуклые объёмы со всеми вытекающими проблемами.


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


    Не планируете ли вы осветить эти вопросы? К сожалению по three.js почти нет информации по чуть более глубоким вопросам, нежели элементарное создание сцен. Даже копание в многочисленных примерах на threejs.org часто не помогает.


  1. kovserg
    22.12.2018 12:24

    Прикольно
    image