Однажды мы делали простой браузерный проект со звёздами: как будто мы летим сквозь космос, а звёзды пролетают мимо. Сегодня сделаем красивее: нарисуем Луну, которая вращается вокруг Земли и отбрасывает на неё тень.
Что понадобится
Если бы мы программировали этот проект с нуля, то нам бы пришлось писать много кода:
- создавать и описывать виртуальные источники света;
- учить объекты правильно реагировать на освещение;
- рассчитывать тени на объектах;
- программировать движение объектов;
- обрабатывать реакции на зум и вращение мышью;
- и продумывать ещё десяток разных мелочей.
Вместо этого мы возьмём библиотеку LUME, которая умеет всё это делать сама, а нам остаётся задать параметры объектов и указать, что они там делают. После этого LUME всё отрисует на WebGL — специальной библиотеке трёхмерной графики для веба. Мы уже работали с её начальной версией, когда делали цветной арканоид на JavaScript.
❤️ Обратите внимание: благодаря распространению веба в него приходит всё больше технологий. Сначала пришёл звук и видео, потом полноценное программирование, теперь — трёхмерная графика. И всё это работает в браузере, без установки дополнительного софта. Вот она, силушка веба! Поэтому мы рекомендуем начинать именно с него.
Как работают сцены в движках трёхмерной графики
Сцена — это то, что видит зритель после запуска кода. Обычно сцена состоит:
- из камеры — с какой точки зритель увидит всю картину;
- источника света — у него есть яркость, направление и удалённость от кадра;
- объектов, которые видит зритель.
Задача движка — обработать свет от источника так, чтобы он правильно всё осветил, тени упали куда нужно и чтобы всё двигалось так, как задумано. Если мы сменим положение камеры, то движок всё пересчитает на лету.
Основной элемент трёхмерной графики — это ассеты и текстуры. Про них поговорим ниже, когда будем рассказывать про звёздное небо.
В итоге всё работает так:
- Мы описываем источник света, камеры и объекты.
- Настраиваем, что они делают и как взаимодействуют друг с другом.
- Движок всё это пересчитывает и показывает нам.
- В итоге мы можем посмотреть всю сцену целиком, приближаться к объектам и удаляться от них и поворачивать сцену мышкой под любым углом:
Создаём страницу и настраиваем стили
Нам понадобится наш обычный HTML-шаблон, который мы будем постепенно наполнять жизнью. Ещё у нас пока нет скрипта, но это не мешает нам сразу его подключить:
<!DOCTYPE html>
<html lang="ru" >
<head>
<meta charset="UTF-8">
<title>Земля и Луна</title>
<!-- подключаем свои стили -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- подключаем наш скрипт -->
<script src="script.js"></script>
</body>
</html>
Так как Земля и Луна находятся в космосе, сделаем сразу тёмный фон и укажем, что страница будет занимать всё доступное ей место в браузере. Это единственные настройки стилей, которые нам понадобятся — остальное сделаем на движке:
/* общие настройки страницы */
html,
body {
background: #222;
width: 100%;
height: 100%;
margin: 0;
}
Добавляем звёзды
Добавим звёзды, чтобы создать ощущение космоса. Можно добить тысячи звёзд вручную и по одной, а можно использовать ассеты и текстуры.
Ассеты — это какой-то набор ресурсов, который используется для создания объекта. Например, в движке может быть стандартный ассет «Мяч», в котором есть такие параметры:
- размер,
- вес,
- расцветка,
- звук удара по мячу,
- текстура (кожа, ткань, бумага, металл).
Можно взять стандартный ассет «Мяч», указать нужные параметры и сразу использовать его в своей сцене. Нам не нужно будет отрисовывать его с нуля — движок уже про него знает и сделает всё сам.
Второй важный элемент — текстура. Если совсем по-простому, то текстура — это картинка, которая натягивается на каркас объекта. Если возьмём текстуру «Ткань» и натянем её на виртуальный кубик, то он будет выглядеть как тканевый кубик. Если возьмём текстуру «Дерево», то получим кубик из дерева, а если «Металл» — то металлический.
Теперь, когда мы это знаем, создадим звёзды так: нарисуем огромный шар и на внутренней стороне разместим текстуру со звёздами. Если мы будем находиться в центре этого шара, то будет казаться, что вокруг нас космос.
Чтобы это сделать, добавим такой код:
<!-- подключаем LUME -->
<script src="https://unpkg.com/lume@0.3.0-alpha.11/dist/global.js"></script>
<!-- по умолчанию сцена занимает всё доступное место, в нашем случае — весь раздел body -->
<lume-scene id="scene" webgl >
<!-- настраиваем положение камеры -->
<lume-camera-rig align-point="0.5 0.5 0.5" initial-polar-angle="0" min-distance="90" max-distance="1000" initial-distance="500"></lume-camera-rig>
<!-- заполняем пространство звёздами -->
<lume-sphere id="stars" texture="https://assets.codepen.io/191583/galaxy_starfield.png" receive-shadow="false" has="basic-material" sidedness="back" size="4000 4000 4000" align-point="0.5 0.5 0.5" mount-point="0.5 0.5 0.5" color="white"></lume-sphere>
</lume-scene>
Всё начинается с тега <lume-scene>
— он говорит браузеру, что начинается новая сцена, которую нужно отрисовать. После этого мы установили камеру, указали, как далеко или близко можно двигаться внутри сцены, и настроили расстояние от камеры до сцены на старте: initial-distance="500"
.
После этого мы сделали звёзды. Расскажем подробно про каждый элемент, потому что остальное будем делать точно так же:
<lume-sphere id="stars" ← создали шар и назвали его stars;
texture="https://assets.codepen.io/191583/galaxy_starfield.png" ← показали, откуда взять текстуру;
receive-shadow="false" ← выключили тени, чтобы на звёзды ничего не отбрасывало тень;
has="basic-material" ← указали, что нам нужно просто покрыть всё текстурой без каких-то эффектов;
sidedness="back" ← текстуру размещаем изнутри шара;
size="4000 4000 4000" ← размеры нашего шара по трём осям;
align-point="0.5 0.5 0.5" mount-point="0.5 0.5 0.5" ← помещаем шар по центру сцены;
color="white"> ← указываем цвет звёзд, потому что у нас дырявая текстура и на месте звёзд на самом деле прозрачность, её нужно заполнить каким-то цветом;
</lume-sphere> ← закончили описывать шар.
Единственная проблема — ничего не сработает, если мы обновим страницу в браузере:
Это сообщение означает, что браузер не может обработать наши элементы, потому что мы их не определили и не сказали, что их можно запускать. Исправим это и добавим первую строчку в файле script.js, который мы подключили заранее:
// на старте запускаем LUME
LUME.defineElements();
Теперь всё в порядке и мы видим звёзды:
Включаем свет
Добавим в сцену источник света. Разницы между просто звёздами и звёздами со светом мы не увидим, но это нам пригодится на следующих этапах. Добавляем в сцену такой код:
<!-- добавляем свет от солнца -->
<!-- настраиваем направление света -->
<lume-node align-point="0.5 0.5" size="0 0" rotation="0 -50 0">
<lume-node align-point="0.5 0.5" size="0 0" rotation="10 0 0">
<!-- добавляем источник света -->
<lume-point-light id="light" size="0 0" position="0 0 1800" color="white" intensity="2" distance="10000" shadow-map-width="2048" shadow-map-height="2048" shadow-camera-far="20000"></lume-point-light>
</lume-node>
</lume-node>
Тут у нас появился новый элемент — lume-node. Он отвечает за объект в сцене: где он находится, что делает, как реагирует на остальное, поэтому мы сначала создаём новый виртуальный объект, а потом добавляем конкретики с помощью вложенных тегов.
Источник света — полноценный объект, поэтому мы описываем его по всем правилам: указываем яркость, расстояние до сцены, направление света, удаление от камеры и остальные параметры.
Рисуем Землю
Нарисуем Землю точно так же, как мы рисовали звёзды: используем шар и текстуры. Нам понадобятся специальные текстуры, которые выглядят примерно так:
Похоже на глобус, но со странными пропорциями по краям. Это сделано специально: если такую текстуру обернуть вокруг шара, то всё станет на свои места с правильными пропорциями.
Ещё нам понадобится карта глубины и кадра отражений. Глубина отвечает за рельеф и объём Земли: что выше, а что ниже. Чем светлее элемент карты, тем выше он приподнимет текстуру в этом месте:
Карта отражений скажет движку, что и как может отражать свет (и на что может падать тень), а что нет. У нас будет простая карта: на сушу будет падать тень от Луны, а на воду — нет.
<!-- Рисуем Землю -->
<lume-node align-point="0.5 0.5" size="0 0 0">
<!-- включаем вращение Земли -->
<lume-node rotation="0 180 0">
<!-- Земля — это шар, поэтому рисуем шар и натягиваем на него текстуры: изображение Земли, карту глубины и карту отражений-->
<lume-sphere id="earth"
texture="https://assets.codepen.io/191583/earthmap1k.jpg"
bump-map="https://assets.codepen.io/191583/earthbump1k.jpg"
specular-map="https://assets.codepen.io/191583/earthspec1k.jpg"
size="120 120 120" mount-point="0.5 0.5 0.5" align-point="0.5 0.5" color="white">
</lume-node>
</lume-node>
Вот что у нас получилось:
А вот что мы бы получили, если бы не добавили карту глубины — всё плоское и без теней. Надо понимать, что к реальному виду Земли из космоса это не имеет отношения, потому что из космоса Земля как раз выглядит как идеально гладкий шарик — колебания высоты разглядеть крайне сложно. Но мы делаем красоту, поэтому нам можно.
Рисуем облака
Чтобы Земля выглядела естественно, добавим облака (типа атмосфера). Сделаем это тоже через шар, только слегка прозрачный и с текстурой облаков:
<!-- добавляем облака -->
<lume-sphere id="clouds" texture="https://assets.codepen.io/191583/earthclouds.png" opacity="0.7" size="125 125 125" align-point="0.5 0.5 0.5" mount-point="0.5 0.5 0.5" color="white"></lume-sphere>
</lume-sphere>
Добавляем Луну
Последний объект, который нам осталось добавить, — это Луна. Делаем её так же, как остальное: рисуем шар и натягиваем текстуру. Единственное отличие — мы сразу прописываем, как ей нужно будет вращаться вокруг Земли. Так как мы поставили Землю в центр сцены, то для вращения Луны вокруг Земли мы добавим параметр rotation
и говорим, что он будет вращаться по оси Z:
<!-- рисуем Луну -->
<lume-node align-point="0.5 0.5" rotation="90 10 0">
<!-- указываем, как ей вращаться вокруг Земли -->
<lume-node id="moonRotator" align-point="0.5 0.5" rotation="0 0 110">
<!-- рисуем круг и добавляем на него текстуру -->
<lume-sphere texture="https://assets.codepen.io/191583/moon.jpg" position="250" size="5 5 5" mount-point="0.5 0.5 0.5" color="white"></lume-sphere>
</lume-node>
</lume-node>
Запускаем вращение
У нас на странице всё готово для того, чтобы мы запустили вращение Земли, Луны и облаков. С вращением Земли и облаков вокруг неё всё просто: мы просто задаём вращение по оси Y в зависимости от времени, а остальные координаты не трогаем. Чтобы облака вращались медленнее, чем Земля, сделаем коэффициент умножения в 3 раза меньше:
// вращаем Землю
earth.rotation = (x, y, z, t) => [x, t * 0.01, z];
// и немного медленнее — вращаем облака вокруг Земли
clouds.rotation = (x, y, z, t) => [x, -t * 0.003, z];
А с Луной немного сложнее: у неё не будет вращения по оси Y (потому что Луна всё время повёрнута к Земле одной стороной), а вместо этого она будет вращаться по оси Z вокруг Земли. Чтобы нам не городить код со стартовым углом вращения и скоростью полёта, используем время: мы просто будем каждый раз считать разницу во времени и в зависимости от неё пересчитывать координаты. Звучит сложно, но на деле это несколько строк:
// получаем текущее время
let lastTime = performance.now();
// сдвиг по времени, на старте равен нулю
let dt = 0;
// вращаем Луну
moonRotator.rotation = (x, y, z, time) => {
// получаем разницу во времени
dt = time - lastTime;
// делаем старое время текущим
lastTime = time;
// возвращаем новые координаты Луны
return [x, y, z + dt * 0.01];
};
Собираем всё в один файл и обновляем страницу: всё начало вращаться, Луна летает и отбрасывает тень на Землю:
Посмотреть на Землю в космосе на странице проекта.
<!DOCTYPE html>
<html lang="ru" >
<head>
<meta charset="UTF-8">
<title>Земля и Луна</title>
<!-- подключаем свои стили -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- подключаем LUME -->
<script src="https://unpkg.com/lume@0.3.0-alpha.11/dist/global.js"></script>
<!-- по умолчанию сцена занимает всё доступное место, в нашем случае — весь раздел body -->
<lume-scene id="scene" webgl >
<!-- настраиваем положение камеры на старте и в общем -->
<lume-camera-rig align-point="0.5 0.5 0.5" initial-polar-angle="0" min-distance="90" max-distance="1000" initial-distance="500"></lume-camera-rig>
<!-- заполняем пространство звёздами -->
<lume-sphere id="stars" texture="https://assets.codepen.io/191583/galaxy_starfield.png" receive-shadow="false" has="basic-material" sidedness="back" size="4000 4000 4000" align-point="0.5 0.5 0.5" mount-point="0.5 0.5 0.5" color="white"></lume-sphere>
<!-- добавляем свет от солнца -->
<!-- настраиваем направление света -->
<lume-node align-point="0.5 0.5" size="0 0" rotation="0 -50 0">
<lume-node align-point="0.5 0.5" size="0 0" rotation="10 0 0">
<!-- добавляем источник света -->
<lume-point-light id="light" size="0 0" position="0 0 1800" color="white" intensity="2" distance="10000" shadow-map-width="2048" shadow-map-height="2048" shadow-camera-far="20000"></lume-point-light>
</lume-node>
</lume-node>
<!-- Рисуем Землю -->
<lume-node align-point="0.5 0.5" size="0 0 0">
<!-- включаем вращение Земли -->
<lume-node rotation="0 180 0">
<!-- Земля — это шар, поэтому рисуем шар и натягиваем на него текстуры: изображение Земли, карту глубины и карту отражений-->
<lume-sphere id="earth"
texture="https://assets.codepen.io/191583/earthmap1k.jpg"
bump-map="https://assets.codepen.io/191583/earthbump1k.jpg"
specular-map="https://assets.codepen.io/191583/earthspec1k.jpg"
size="120 120 120" mount-point="0.5 0.5 0.5" align-point="0.5 0.5" color="white">
<!-- добавляем облака -->
<lume-sphere id="clouds" texture="https://assets.codepen.io/191583/earthclouds.png" opacity="0.7" size="125 125 125" align-point="0.5 0.5 0.5" mount-point="0.5 0.5 0.5" color="white"></lume-sphere>
</lume-sphere>
</lume-node>
<!-- рисуем Луну -->
<lume-node align-point="0.5 0.5" rotation="90 10 0">
<!-- указываем, как ей вращаться вокруг Земли -->
<lume-node id="moonRotator" align-point="0.5 0.5" rotation="0 0 110">
<!-- рисуем круг и добавляем на него текстуру -->
<lume-sphere texture="https://assets.codepen.io/191583/moon.jpg" position="250" size="5 5 5" mount-point="0.5 0.5 0.5" color="white"></lume-sphere>
</lume-node>
</lume-node>
</lume-node>
</lume-scene>
<!-- подключаем наш скрипт -->
<script src="script.js"></script>
</body>
</html>
/* общие настройки страницы */
html,
body {
background: #222;
width: 100%;
height: 100%;
margin: 0;
}
clouds.rotation = (x, y, z, t) => [x, -t * 0.003, z];// на старте запускаем LUME
LUME.defineElements();
// получаем текущее время
let lastTime = performance.now();
// сдвиг по времени, на старте равен нулю
let dt = 0;
// вращаем Луну
moonRotator.rotation = (x, y, z, time) => {
// получаем разницу во времени
dt = time - lastTime;
// делаем старое время текущим
lastTime = time;
// возвращаем новые координаты Луны
return [x, y, z + dt * 0.01];
};
// вращаем Землю
earth.rotation = (x, y, z, t) => [x, t * 0.01, z];
// и немного медленнее — вращаем облака вокруг Земли