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


Почему Three.js


three.js — это 3D-библиотека JavaScript на WebGL, API для рендеринга 2D- и 3D-моделей в браузере. Графический процессор для обработки рендеринга three.js позволяет эффективно управлять 3D-моделями напрямую в обычном canvas HTML.


Вполне возможно отрисовать сердце только на WebGL, но богатый API three.js сильно упрощает работу.


three.js предоставляет рендереры Canvas 2D, SVG и CSS3D, но придерживаться будем WebGL.

Установить three.js можно по-разному, а мы загрузим его через CDN. Напишем классический index.html и загрузить библиотеку в теге script. Затем поработаем с файлом heart.js. Элемент canvas будет добавлен автоматически.


<html>
  <head>
  <meta charset="utf-8">
  <title>in my heart</title>
  </head>
  <body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r124/three.min.js"></script>
    <script src="./heart.js"></script>
  </body>
</html>

Сцена


Поместить свое произведение искусства в подходящий пейзаж — это меньшее, что можно сделать. К счастью, работать можно с инструментами three.js.


Начнем с функции:


function createScene () {
    const  scene = new THREE.Scene()
    const  camera = new THREE.PerspectiveCamera(60,  window.innerWidth / window.innerHeight, 1, 100)
    camera.position.z = 30

    const  renderer = new THREE.WebGLRenderer({ antialias: true })
    renderer.setSize(window.innerWidth, window.innerHeight)
    document.body.appendChild(renderer.domElement)

    const color = 0xFFFFFF
    const intensity = 0.75
    const light = new THREE.PointLight(color, intensity)
    light.position.set(-15, -10, 30)

    scene.add(light)

    return {
        scene,
        camera,
        renderer
    }
}

Нужно определить объект сцены с почти всем, что будет визуализироваться. Это большая работа, но в одну строку. Затем нужно создать объект камеры — точку, откуда видно сцену. Three.js предоставляет разные типы камер, но для нашей ситуации хорошо подходит эта:


«Этот режим проецирования предназначен для имитации того, как видит человеческий глаз. Это наиболее распространенный режим проекции рендеринга 3D-сцены».

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


camera.position.z = 30

Камеру нужно размещать дальше от фигуры.


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

Теперь создадим экземпляр объекта WebGLRenderer. Саму модель можно сглаживать или нет, по желанию.


renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)

Устанавливаем соответствующий окнам размер, присоединяем холст к телу.


const color = 0xFFFFFF
const intensity = 0.75
const light = new THREE.PointLight(color, intensity)
light.position.set(-15, -10, 30)
scene.add(light)

Добавим в сцену источник света. Я выбрал PointLight, ведь его довольно легко визуализировать. Он как лампочка, то есть без направления и чего-либо еще.


Внутри точки входа нашей программы — метода init — вызовем createScene.


function init () {
 const {scene, camera, renderer} = createScene()
}
init()

Сердце


Теперь можно приступить к работе над звездой… сердцем шоу! Мы работаем с рендерером WebGL, поэтому можно легко рисовать треугольники с координатами точек (или вершин). Определим эти координаты — и тогда мы увидим, как между ними всеми нарисовать треугольники.


Координаты


Нам нужно определить множество вершин модели. Считайте, что это точки, соединяющие рёбра фигуры. В three.js их можно настроить в объекте Vector3. Положения рёбер могут быть совершенно произвольными. Я вычислил их на бумаге, но здесь схитрю и покажу подобранные координаты на готовой модели:



Координаты точек на передней стороне сердца


Каждая точка имеет три координаты (x, y, z), а все окружающие края лежат на плоскости z=0.
Покажу, как это выглядит с другого ракурса:



Модель сердца сбоку


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


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


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


Треугольники


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


Теперь пришло время взглянуть на вторую функцию:


function useCoordinates () {
  const vertices = [
    new THREE.Vector3(0, 0, 0), // point C
    new THREE.Vector3(0, 5, -1.5),
    new THREE.Vector3(5, 5, 0), // point A
    new THREE.Vector3(9, 9, 0),
    new THREE.Vector3(5, 9, 2),
    new THREE.Vector3(7, 13, 0),
    new THREE.Vector3(3, 13, 0),
    new THREE.Vector3(0, 11, 0),
    new THREE.Vector3(5, 9, -2),
    new THREE.Vector3(0, 8, -3),
    new THREE.Vector3(0, 8, 3),
    new THREE.Vector3(0, 5, 1.5), // point B
    new THREE.Vector3(-9, 9, 0),
    new THREE.Vector3(-5, 5, 0),
    new THREE.Vector3(-5, 9, -2),
    new THREE.Vector3(-5, 9, 2),
    new THREE.Vector3(-7, 13, 0),
    new THREE.Vector3(-3, 13, 0),
  ];
  const trianglesIndexes = [
  // face 1
    2,11,0, // This represents the 3 points A,B,C which compose the first triangle
    2,3,4,
    5,4,3,
    4,5,6,
    4,6,7,
    4,7,10,
    4,10,11,
    4,11,2,
    0,11,13,
    12,13,15,
    12,15,16,
    16,15,17,
    17,15,7,
    7,15,10,
    11,10,15,
    13,11,15,
  // face 2
    0,1,2,
    1,9,2,
    9,8,2,
    5,3,8,
    8,3,2,
    6,5,8,
    7,6,8,
    9,7,8,
    14,17,7,
    14,7,9,
    14,9,1,
    9,1,13,
    1,0,13,
    14,1,13,
    16,14,12,
    16,17,14,
    12,14,13
  ]
  return {
    vertices,
    trianglesIndexes
  }
}

Первый массив (vertices) представляет все формирующие модель точки, от 0 до 17. Второй (triangleIndexes) — все треугольники, которые хочется отрисовать по этим точкам. Это просто массив целых чисел — индексы вершин в первом массиве.


Дело в том, что для каждых трех индексов можно сформировать треугольник с тремя соответствующими точками первого массива. Первый треугольник на рисунке соответствует упомянутым выше точкам A, B и C.


Сетка (mesh — меш)


Теперь можно было попросить three.js рисовать треугольники без связи друг с другом, ведь гораздо лучше иметь один объект, ссылающийся на модель. В three.js этот объект называется mesh. Чтобы создать mesh, нужно два объекта — это geometry и material:


function createHeartMesh (coordinatesList, trianglesIndexes) {
    const geo = new THREE.Geometry()
    for (let i in trianglesIndexes) {
        if ((i+1)%3 === 0) {
            geo.vertices.push(coordinatesList[trianglesIndexes[i-2]], coordinatesList[trianglesIndexes[i-1]], coordinatesList[trianglesIndexes[i]])
            geo.faces.push(new THREE.Face3(i-2, i-1, i))
        }
    }
    geo.computeVertexNormals()
    const material = new THREE.MeshPhongMaterial( { color: 0xad0c00 } )
    const heartMesh = new THREE.Mesh(geo, material)
    return {
        geo,
        material,
        heartMesh
    }
}

Считайте geometry эталоном формы, а material — эталоном текстуры или ткани фигуры.


Нам понадобится еще одна функция:


function addWireFrameToMesh (mesh, geometry) {
    const wireframe = new THREE.WireframeGeometry( geometry )
    const lineMat = new THREE.LineBasicMaterial( { color: 0x000000, linewidth: 2 } )
    const line = new THREE.LineSegments( wireframe, lineMat )
    mesh.add(line)
}

Сначала мы создаем geometry по умолчанию. Нет необходимости в дальнейшей настройке, мы опишем все её грани.


for (let i in trianglesIndexes) {
 if ((i+1)%3 === 0) {
  geo.vertices.push(coordinatesList[trianglesIndexes[i-2]], coordinatesList[trianglesIndexes[i-1]], coordinatesList[trianglesIndexes[i]])
  geo.faces.push(new THREE.Face3(i-2, i-1, i))
 }
}

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


Теперь, когда геометрия имеет эти три вершины, к ней можно добавить face. Объект Face3 в three.js определяется тремя индексами вершин. Мы уже сопоставили точки и треугольники, то есть грани; это те же самые индексы, что использовались при добавлении вершин к геометрии.


Кстати, по умолчанию three.js одновременно отображает только лицевую сторону грани. Она определяется порядком индексов, поэтому, если не работает, возможно, нужно изменить этот порядок.

Настроив свой материал, можно визуализировать обе стороны грани.

И теперь, определившись с геометрией, можно создать material:


geo.computeVertexNormals()
const material = new THREE.MeshPhongMaterial( { color: 0xad0c00 } )

Воспользуемся специальным особым материалом, отражающим свет. Для этого перед ним нужно вызвать computeVertexNormals. Материал сделаем темно-красным. Добавим его в эту функцию init:


function  init () {
 const {scene, camera, renderer} = createScene()
 const { vertices, trianglesIndexes} = useCoordinates()
 const { geo, material, heartMesh } = createHeartMesh(vertices, trianglesIndexes)

Каркас


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


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


Модель в сцену


scene.add(heartMesh)

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


Рендеринг


Рендеринг достаточно прост: создадим экземпляр объекта рендерера — и у нас появится все необходимое. Но рендеринг будет вызываться регулярно, особенно при анимации. Сегодня обычная частота плавного движения — это шестьдесят кадров в секунду.


Можно было прописать setInterval и установить его равным шестидесятой доле секунды, но, не слишком полагаясь на основной поток, браузеры (для того же эффекта) предоставляют функцию requestAnimationFrame.


const animate = function () {
 requestAnimationFrame( animate )
 renderer.render( scene, camera )
 heartMesh.rotation.y -= 0.005
}
animate()

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


heartMesh.rotation.y -= 0.005

Чем выше приращение, тем быстрее вращение.


Попробуем вызвать все это в функции init:


(function init () {
  const {scene, camera, renderer} = createScene()
  const { controls } = setControls(camera, renderer.domElement, window.location.hash.includes('deviceOrientation'))
  const { vertices, trianglesIndexes} = useCoordinates()
  const { geo, material, heartMesh } = createHeartMesh(vertices, trianglesIndexes)
  scene.add(heartMesh)
  addWireFrameToMesh(heartMesh, geo)
  const { onMouseIntersection } = handleMouseIntersection(camera, scene, heartMesh.uuid)

  window.addEventListener( 'click', onMouseIntersection, false )

  const animate = function () {
    requestAnimationFrame( animate )
    renderer.render( scene, camera )
    heartMesh.rotation.y -= 0.005
    startAnim && beatingAnimation(heartMesh)
    controls.update()
  }
  animate()
})()

Теперь, если вы решили, что способны справиться с чем-то большим, добавим анимацию сложнее и посмотрим, как запустить её вводом от пользователя.


Cердцебиение


Давайте оживим модель другой анимацией. Учтём всё, чему научились, работая с анимацией вращения:


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


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


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


Эта функция будет вызываться несколько раз функцией animate. Хотя она выглядит как код, который можно написать внутри while, итерации запускаются рекурсивным рендерингом сцены.


Добавим функцию animate:


const animate = function () {
  requestAnimationFrame( animate )
  renderer.render( scene, camera )
  heartMesh.rotation.y -= 0.005
  beatingAnimation(heartMesh)
}

Есть пульс!


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


Интерактивность


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


Воспользуемся бросанием лучей (рейкастингом), чтобы понять, как обрабатывать события кликов на холсте. После настроим элементы управления камерой.


Обработка пользовательского ввода с бросанием лучей.


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


function handleMouseIntersection (camera, scene, meshUuid) {
  const raycaster = new THREE.Raycaster();
  const mouse = new THREE.Vector2();

  function onMouseIntersection( event ) {
      const coordinatesObject = event.changedTouches ? event.changedTouches[0] : event
      mouse.x = ( coordinatesObject.clientX / window.innerWidth ) * 2 - 1;
      mouse.y = - ( coordinatesObject.clientY / window.innerHeight ) * 2 + 1;

      raycaster.setFromCamera( mouse, camera );
      const intersects = raycaster.intersectObjects( scene.children );

      if (intersects.length && intersects[0].object.uuid === meshUuid) {
          startAnim = true
      }
  }

  mouse.x = 1
  mouse.y = 1

  return {
      onMouseIntersection
  }
}

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


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


if (intersects.length && intersects[0].object.uuid === meshUuid) {
    startAnim = true
}

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


const animate = function () {
 requestAnimationFrame( animate )
 renderer.render( scene, camera )
 heartMesh.rotation.y -= 0.005
 startAnim && beatingAnimation(heartMesh)
}

и переключиться на false, когда анимация закончится.


Нам нужно изменить функцию beatingAnimation. Вот как это сделать:


function beatingAnimation (mesh) {
  // [...]
   if (mesh.scale.x <= 1) {
    scaleThreshold = false
    startAnim = false // we must stop it right here or it will start over again
   }
 }
}

Можно вызвать handleMouseIntersection из функции init, а затем подключить возвращаемый этой функцией обработчик к прослушивателю кликов объекта окна.


И теперь сердцебиение управляемо!


Элементы управления


Вы можете собой гордиться. Ваша модель перед вами, и вы получили контроль над ее анимацией, но как насчет того, чтобы вы могли любоваться ею со всех сторон? Давайте сначала попробуем добавить элементы управления орбитой, а затем — для пользователей мобильных — использовать API ориентации устройства.


Управление орбитой


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


У three.js eсть ненативный API настройки элементов управления. Добавить его можно в тег script файла index.html:


<script src="https://threejs.org/examples/js/controls/OrbitControls.js"></script>

Теперь у нас есть доступ к его конструктору через объект THREE. Давайте создадим его внутри выделенной функции:


function setControls (camera, domElement) {
 const controls = new  THREE.OrbitControls( camera, domElement )
 controls.update()
}

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


Диапазон перемещений камеры можно ограничить; это может оказаться полезным.

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


Элементы управления ориентацией устройства


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


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


Код для DeviceOrientationControls можно найти в репозитории three.js на GitHub, в разделе примеров, но он ссылается на папку сборки, которой в проекте нет. Можно создать свою папку, или работать с этим файлом.


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


На этот раз нам нужно вернуть ссылку controls, потому что при работе с DeviceOrientationControls внутри цикла рендеринга нужно вызывать controls.update().


const  animate = function () {
 requestAnimationFrame( animate )
 renderer.render( scene, camera )
 heartMesh.rotation.y -= 0.005
 startAnim && beatingAnimation(heartMesh)
 controls.update() // this line is new
}
animate()

Режим управления легко преключается параметром setControls. Это переключение нетрудно связать со вводом пользователя.


const { controls } = setControls(camera,  renderer.domElement, true)

Возможно, есть способ объединить оба элемента управления в один и получить полное управление камерой, но думаю, что это может запутать.
Надеюсь, вы узнали что-то из моего опыта. Спасибо, что прочитали!





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


  1. mishamota
    00.00.0000 00:00
    +4

    Демка не работает

    Автор оригинала не очень знаком с официальной докой

    If three.js was installed from a CDN, use the same code, but with three/addons/ in the import map.

    <script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
    
    <script type="importmap">
      {
        "imports": {
          "three": "https://unpkg.com/three@<version>/build/three.module.js",
          "three/addons/": "https://unpkg.com/three@<version>/examples/jsm/"
        }
      }
    </script>
    
    <script type="module">
    
      import * as THREE from 'three';
      import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
    
      const controls = new OrbitControls( camera, renderer.domElement );
    
    </script>


  1. Rodionbgd
    00.00.0000 00:00

    Немного доработал для актуальной версии three.js

    https://github.com/rodionbgd/3d-heart