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