Всевозможные презентации товаров в 3D – не такая уж и редкость в наше время, но эти задачи вызывают массу вопросов у начинающих разработчиков. Сегодня мы посмотрим некоторые основы, которые помогут войти в эту тему и не спотыкаться о такую простую задачу, как отображение трехмерной модельки в браузере. В качестве подспорья будем использовать THREE.js как самый популярный инструмент в этой области.
Приступаем к работе
Первым делом сделаем себе HTML-заготовку. Чтобы не усложнять пример не будем использовать ничего лишнего, никаких сборщиков, препроцессоров и.т.д.
Нам понадобится контейнер для канваса и набор скриптов – собственно three.js, загрузчик для моделей в формате obj, и скрипт для управления камерой с помощью мыши.
Если у вас в проекте используются 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 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” будем использовать текстуру:
Для частей из материалов “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 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.
Теперь цвета меняются плавно, но только после того, как мышка уходит в сторону от шарика. Нужно как-то это исправить. Для этого воспользуемся символами – они позволяют безопасно добавлять мета-информацию к объектам, а нам как раз и нужно добавить информацию о том, анимирован шарик или нет.
Символы в ES6+ — это очень мощный инструмент, который помимо прочего позволяет добавлять информацию к объектам из сторонних библиотек, не опасаясь, что это приведет к конфликту имен или сломает логику.
Делаем глобальную константу (по идее стоило бы сделать глобальный объект для всех подобных символов, но у нас простой пример, не будем его усложнять):
const _IS_ANIMATED = Symbol('is animated');
И добавляем проверку в функцию перекрашивания шариков:
Теперь они плавно перекрашиваются сразу при наведении мыши. Таким образом с помощью символов можно быстро добавлять подобные проверки в анимациях без сохранения состояний всех шариков в отдельном месте.
Всплывающие подсказки
Последняя вещь, которую мы сегодня сделаем – это всплывающие подсказки. Эта задача часто встречается. Для начала нам нужно просто их сверстать.
Не забывайте отключать 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);
}
На данный момент ничего не произошло. Собственно мы ничего и не сделали. Инициализируем всплывающий элемент, можем сразу поиграть с его размером и положением в пространстве:
Теперь мы видим надпись “в 3D”. На самом деле она не совсем в 3D, она лежит поверх канваса, но для всплывающих подсказок это не так важно, важен эффект
Остается последний штрих – плавно показывать надпись в определенном диапазоне углов. Снова используем глобальный символ:
const _IS_VISIBLE = Symbol('is visible');
И обновляем состояние всплывающего элемента в зависимости от угла поворота камеры:
Сегодня мы посмотрели на то, как выводить трехмерные модели к себе на страницу, как крутить их мышкой, как сделать всплывающие подсказки, как реагировать на наведения мыши на определенные части модели и как можно применять символы в контексте различных анимаций. Надеюсь эта информация будет полезна. Ну а всех с наступающим, теперь вы знаете, что можно изучить на каникулах.
P.S.: Полные исходники примера с елочкой доступны на гитхабе.