Воссоздание сцены из обучающего видео игры PORTAL с помощью CSS

Чтобы создавать в браузере внушительные 3D-проекты не нужен JavaScript. В этой статье я покажу, как создать и анимировать сцену по мотивам игры Portal, используя исключительно CSS.

Вы можете посмотреть демо онлайн или получить исходники на Github.

Установка порталов. Видео 1.

Как пример, который иллюстрирует различные стадии моделирования, написания стилей и анимации для 3D-сцены, в этой статье я воссоздам сцену из обучающего видео игры Portal. А именно, стилизированную, мультяшную первую часть видео, где напоминающий силуэт персонаж прыгает через портал и возникает на другой его стороне. Вот оригинальное видео:

Это видео сорвало мне крышу, когда я впервые его увидел. Portal — это забавное отклонение в 3D-жанре, которое было разработанно, основываясь на игре Narbacular Drop.

Изометрия и мультяшная графика во вступительном ролике сильно отличаются от самой игры, но, тем не менее, игре удалось отчасти передать этот стиль. Я расскажу, как воссоздать этот мультяшный стиль, используя CSS и HTML.

Мы создадим следующую сцену:

Иллюстрация

Коротко о префиксах

Я убрал у CSS-свойств префиксы. Рекомендую либо использовать что то вроде prefix free, либо SASS. В противном случае большинство браузеров потребуют от вас свойств с префиксами. Вы можете найти полные версия CSS и SASS на Github, как и HTML.

Исследования, которые еще предстоит провести

Проект разрабатывался и тестировался в основном в Chrome, он использует CSS который не будет работать в Internet Explorer и включает некоторые интересные приемы использования CSS для получения эффекта трехмерности, которые слабо распространены, несмотря на то, что доказали свою ценность в других областях фронтенд-разработки.

Начнем

Нам нужно создать сцену которая будет содержать 3D-объекты. Для этого нам понадобится создать HTML элемент и наделить его необходимыми свойствами, что бы браузер понимал, что внутри находится 3D-контекст. Начнем с HTML:

<article class="container">...</article>

Контейнером будет тэг article. В HTML5, article представляет независимый блок данных который можно воспроизвести в любом другом месте и он не потеряет своего смысла.

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

Иллюстрация

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

article.container {
    perspective: 2600px;
}

Точка схода

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

.container {
    perspective-origin: 50% -1400px;
}

Свойство perspective-origin принимает два аргумента, горизонтальное и вертикальное смещение. В нашем случае мы установим его посередине сцены и поднимем на 1400 пикселей вверх. В результате наша точка зрения будет над сценой, и мы будем смотреть на неё сверху вниз.

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

Никаких векторов

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

HTML-элементы позиционируются в рамках 3D-сцены, используя свойство transform.

Трансформации

Свойство transform принимает в качестве аргументов трансформации, которые применяются к HTML элементу. Мы можем использовать translate, чтобы передвинуть элемент, rotate, чтобы повернуть его, skew для скоса элемента или scale для масштабирования. Простые трансформации могут применяться последовательно для того, чтобы получить более сложные, например:

.example {
    transform: rotateY(45deg) translateZ(-100px);
}

Это правило повернет элемент на 45 градусов относительно оси Y и затем передвинет его на 100 пикселей по оси Z. Результат будет выглядеть так:

Иллюстрация

Точка, относительно которой происходит трансформация

Когда мы поворачиваем элементы, стоит помнить, что мы можем установить точку, относительно которой будет происходить трансформация. Это делается с помощью свойства transform origin, в качестве значения которого указываются координаты Z, Y и Z этой точки. По умолчанию их значения равны:

.default-origin {
    transform-origin: 50% 50% 0;
}

При создании этой сцены я использовал значения по умолчанию, но стоит знать, что их можно изменить.

Давайте строить

Теперь, когда у нас есть сцена, можем собирать наш 3D-шедевр. Когда мы начинаем создавать 3D-объекты с помощью HTML и CSS, стоит разобраться, как используемый нами подход будет отличаться от тех, которые используются в другом программном обеспечении для 3D-моделирования.

<section class="stage">
    <div class="shadow"></div>
    <div class="back-left"></div>
    <div class="back-right"></div>
    <div class="platform-left"><span></span></div>
    <div class="platform-right"><span></span></div>
    <div class="pit-left"></div>
    <div class="pit-right"></div>
    <div class="pit-back"></div>
</section>

Выше вы видите сцену, на которой мы собрали все нужные нам элементы. Из этих div-элементов мы соберем основные части сцены. Они превратятся в стены, платформы, яму и тень.

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

Имея это ввиду, HTML-элементы позиционируются с помощью трансформаций согласно следующем наброску:

Иллюстрация

Как показано на изображении, элемент с классом back-left расположен слева, но элемент с классом back-right — прямо напротив пользователя. Чтобы исправить это, мы позднее повернем сцену на 45 градусов.

Прежде чем применять трансформации, нам нужно добавить всем элементам общие свойства:

.stage div {
    position: absolute;
    transform-style: preserve-3d;
}

Каждый div будет позиционирован абсолютно, а свойство transform-style укажет браузеру, что 3D-трансформации будут применятся с учетом ранее заданной перспективы.

Теперь можно начать позиционировать элементы сцены:

.stage .back-left {
    width: 500px;
    height: 120px;
    background-color: #6b522b;
    transform: rotateY(90deg) translateX(-256px);
    border-top: 6px solid #8a683d;
    border-left: 6px solid #574625;
}

Это правило задает ширину стены равной 500 пикселям, что равно ширине сцены, высоту равной 120 пикселям и светло-коричневый цвет фона. Элемент поворачивается на 90 градусов и смещается по оси X. У него есть border шириной 6 пикселей, чтобы создать иллюзию объема.

Аналогичную трансформацию мы применяем к элементу back-right:

.stage .back-right {
    width: 446px;
    height: 120px;
    background-color: #9c7442;
    transform: translateX(253px) translateZ(3px);
    border-top: 6px solid #b5854a;
    border-right: 6px solid #78552c;
}

Этот блок несколько меньше, так как комната, которую мы наблюдаем на видео Portal, не совсем квадратная.

Далее, добавим несколько платформ и стены ямы:

.stage .platform-left {
    width: 446px;
    height: 220px;
    background-color: #bcb3a8;
    transform: rotateX(90deg) translateY(396px) translateX(253px) translateZ(-13px);
    border-bottom: 6px solid #857964;
}
.stage .platform-right {
    width: 446px;
    height: 164px;
    background-color: #bcb3a8;
    transform: rotateX(90deg) translateY(88px) translateX(253px) translateZ(-41px);
    border-right: 6px solid #554c3d;
    border-bottom: 6px solid #847660;
}
.stage .pit-left {
    width: 447px;
    height: 800px;
    background-color: #4d4233;
    transform: translate3D(254px, 125px, 285px);
}
.stage .pit-right {
    width: 451px;
    height: 800px;
    top: -1400px;
    background-color: #847660;
    transform: translate3D(254px, 125px, 173px);
}
.stage .pit-back {
    width: 170px;
    height: 220px;
    background-color: #6b522b;
    transform: rotateY(90deg) translate3D(-200px, 87px, 168px);
}

Конечным результатом этого должна быть сцена, выглядящая следующим образом:

Иллюстрация

Пока она не очень похожа на ту, которую мы видили на видео. Нам нужно повернуть её, и мы сделаем это, применив к сцене свойство transform:

.stage {
    width: 460px;
    margin: 0 auto;
    transform-style: preserve-3d;
    transform: rotateY(-45deg);
}

Результат должен выглядеть приблизительно так:

Иллюстрация

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

Тень

В видео за платформами отображается отличная тень. Мы можем ее воспроизвести используя CSS свойство box-shadow.

.stage .shadow {
    width: 550px;
    height: 550px;
    background-color: transparent;
    transform: rotateX(90deg) translateZ(-166px) translateX(550px);
    box-shadow: -600px 0 50px #afa79f;
}

Это правило добавляет прозрачному элементу shadow тень. Тень сдвинута на 600 пикселей, так что реальный элемент shadow и тень не пересекаются. Все это повернуто и позиционировано так, чтобы была видна только часть тени. Результат выглядит как то так:

Иллюстрация

Красный и синий порталы

Давайте добавим немного декораций и светящиеся порталы.

Иллюстрация

HTML, необходимый для создания двух порталов достаточно прост:

<div class="portal red"></div>
<div class="portal blue"></div>

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

В первую очередь давайте зададим форму портала:

.stage .portal {
    width: 48px;
    height: 72px;
    background-color: black;
    border-radius: 44px/62px;
    box-shadow: 0 0 15px 4px white;
}

Таким образом, мы задали размеры портала и использовали свойство border-radius для придания ему овальной формы, а box-shadow позволило получить эффект свечения. Давайте теперь добавим псевдоэлемент аналогичного размера с белой рамкой:

.stage .portal:before {
    content: "";
    display: block;
    width: 48px;
    height: 72px;
    border: 4px solid white;
    border-radius: 44px/62px;
    margin-top: -4px;
    margin-left: -4px
}

Эти стили применяются к обоим порталам. Так как один из них синий, другой —  красный, нам нужно для каждого из них написать дополнительные правила. Для начала, красный портал:

.stage .portal.red {
    background: radial-gradient(#000000, #000000 50%, #ff4640 70%);
    border: 7px solid #ff4640;
    transform: translate3D(223px, 25px, 385px) rotateY(90deg) skewX(5deg);
}
.stage .portal.blue {
    background: radial-gradient(#000000, #000000 50%, #258aff 70%);
    border: 7px solid #258aff;
    transform: translate3D(586px, 25px, 4px) skewX(-5deg);
}

Фон красного портала задается с помощью radial gradient (радиального градиента) и красной рамки. Затем с помощью трансформаций мы позиционируем его у левой стены. У синего портала правило похоже, но градиент — синий? и позиционирован он у правой стены. Оба смотрелись немного кривобоко, так что я добавил скос на 5 градусов, чтобы они смотрелись получше.

Свет от порталов на платформах

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

.stage .platform-left span {
    display: block;
    position: absolute;
    width: 120px;
    height: 200px;
    left: 0;
    background: radial-gradient(left, #f3cac8, #c8b8ad 70px, #bcb3a8 90px);
}
.stage .platform-right span {
    display: block;
    position: absolute;
    width: 150px;
    height: 60px;
    left: 280px;
    background: radial-gradient(top, #cdebe8, #c2cbc1 40px, #bcb3a8 60px);
}

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

Дверь

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

Иллюстрация

HTML, представляющий дверь, очень простой. Добавьте следующую разметку в элемент stage:

<div class="door"></div>

Стилизация двери использует несколько граней рамки и трансформации для того, чтобы позиционировать ее у правой стены:

.stage .door {
    width: 65px;
    height: 85px;
    background: #efe8dd;
    border-bottom: 6px solid #bcb3a8;
    border-left: 7px solid #78552e;
    transform: translate3D(450px, 34px, 4px);
}

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

Персонажи

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

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

Создание персонажа

Иллюстрация

Изначально персонаж состоит из 2х основных частей: головы и туловища. Ноги добавляются к телу с использованием псевдоэлементов. Аналогичной структурой обладает его тень:

<div class="dude one">
    <figure class="head"></figure>
    <figure class="body"></figure>
    <div class="dude-shadow one">
        <figure class="head"></figure>
        <figure class="body"></figure>
    </div>
</div>

Так как тень содержится в контейнере персонажа, она может быть анимирована одновременно с ним. Добавим следующий CSS:

.dude, .dude-shadow {
    width: 30px;
    height: 100px;
}
.dude figure, .dude-shadow figure {
    display: block;
    background-color: black;
    position: absolute;
}
.dude figure.head, .dude-shadow figure.head {
    top: 0;
    left: 3px;
    width: 20px;
    height: 20px;
    border-radius: 22px;
}
.dude figure.body, .dude-shadow figure.body {
    top: 21px;
    width: 26px;
    height: 30px;
    border-radius: 30px 30px 0 0;
}
.dude figure.body:before, .dude figure.body:after, .dude-shadow figure.body:before, .dude-shadow figure.body:after {
    content: "";
    position: absolute;
    width: 9px;
    height: 15px;
    background-color: black;
    top: 30px;
}
.dude figure.body:before, .dude-shadow figure.body:before {
    left: 3px;
}
.dude figure.body:after, .dude-shadow figure.body:after {
    left: 14px;
}

Селекторы дублируются для персонажа и его тени. Каждая часть персонажа позиционируется абсолютно, а свойство border-radius используется для получения овалов. Псевдоэлементы, представляющие из себя ноги, описаны одним правилом и затем позиционированы двумя разными.

Персонаж 1

После того, как мы задали форму персонажа, давайте переместим его в начальную позицию:

.stage .dude.one {
    transform: translate3D(514px, 50px, 375px) rotateY(78deg);
}
.stage .dude-shadow.one {
    transform: translateX(-12px) rotateX(90deg) translateY(8px);
    opacity: 0.1;
}

CSS-трансформации задают одновременно как положение самого персонажа, так и его тени. Мы задаем прозрачность тени равной 0.1, вместо использования непрозрачной тени серого цвета, что позволяет нам видеть детали сцены сквозь тень.

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

Персонаж с руками

Иллюстрация

Второй персонаж более детализирован — у него есть руки. Идея в том, что после того как персонаж прыгает через портал и приземляется с другой стороны, он машет руками от радости. Вот наш HTML:

<div class="dude two">
    <figure class="head"></figure>
    <figure class="body"></figure>
    <figure class="arm left"></figure>
    <figure class="arm right"></figure>
    <div class="dude-shadow two">
        <figure class="head"></figure>
        <figure class="body"></figure>
        <figure class="arm left"></figure>
        <figure class="arm right"></figure>
    </div>
</div>

Второй персонаж в начале анимации невидим, и затем выпрыгивает из портала в середине анимации, когда первый персонаж уже исчез в портале. Для начала позиционируем его:

.stage .dude.two {
    transform: translate3D(610px, 40px, 10px) rotateY(15deg);
}
.stage .dude.two figure.arm {
    position: absolute;
    width: 20px;
    height: 8px;
    background: black;
    top: 20px;
}
.stage .dude.two figure.arm.left {
    left: -13px;
    transform: rotateZ(40deg);
}
.stage .dude.two figure.arm.right {
    right: -10px;
    transform: rotateZ(-40deg);
}
.stage .dude-shadow.two {
    transform:  translateY(12px) translateX(-16px) translateZ(-6px)
                rotateZ(-90deg) rotateY(90deg) rotateZ(50deg)
                skewX(30deg) scaleX(0.8);
    opacity: 0.1;
}

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

Сцена готова

Персонажи и декорации на местах, сцена готова для создания анимации.

Иллюстрация

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

Анимация

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

Анимация по ключевым кадрам

Тайминги и анимации HTML описаны с использованием keyframes и применяются к элементам с помощью свойства animation.

Сперва анимируем первого персонажа, чтобы он приблизился к левому порталу и прыгнул в него. Вот необходимые для этой анимации ключевые кадры:

@keyframes move-dude-one {
    /* Персонаж влетает на сцену */
    0% {
        transform: translate3D(514px, -10px, 375px) rotateY(78deg) scaleY(2);
    }
    /* Стоит на месте */
    1%, 18% {
        transform: translate3D(514px, 50px, 375px) rotateY(78deg) scaleY(1);
        opacity: 1;
    }
    /* Движется к порталу */
    34%, 39% {
        transform: translate3D(284px, 40px, 375px) rotateY(78deg);
        opacity: 1;
    }
    /* Замирает и прыгает внутрь */
    41%, 42% {
        transform: translate3D(234px, 40px, 375px) rotateY(78deg);
        opacity: 1;
    }
    /* Исчезает */
    43%, 100% {
        transform: translate3D(234px, 40px, 375px) rotateY(78deg);
        opacity: 0;
    }
}
/* Примечание: Используйте префиксы, например, @-webkit-keyframes, @-moz-keyframes, и т.п.! */

Ключевые кадры — это серия состояний, через которые проходит анимация, и которые заданы с использованием процентов. Проценты рассчитываются относительно длительности анимации, так что если анимация длится 10 секунд, то 10% будет достигнуто через одну секунду, а 90% — через 9.

Примечание переводчика: это верно только при линейной функции изменения значений в процессе анимации. Она задается свойством animation-timing-function или в рамках комплексного свойства animation.

Чтобы красиво зациклить анимацию персонажа прыгающего в портал, мы создаем все анимации длительностью 10 секунд. Я добавил в код комментарии, чтобы описать каждое состояние, через которое проходит анимация. Свойство transform используется в каждой из них, чтобы задать положение и угол поворота персонажа.

На 43% анимации, прозрачность персонажа устанавливается равной 0. Это тот момент, когда первый персонаж исчезает в портале. Соответственно второй персонаж тоже должен появится на 43% анимации.

Прежде чем сделать это, давайте применим анимацию к первому персонажу:

.dude.one {
    animation: move-dude-one 10s linear infinite;
    opacity: 0;
}

Свойство animation в этом правиле применяет анимацию к элементу .dude.one. В качестве аргументов мы задаем имя анимации, ее длительность, которую мы устанавливаем равной 10 секундам, и ключевое слово infinite, которое говорит, что анимация будет проигрываться бесконечное количество раз.

Прозрачность выставлена равной 0, чтобы быть уверенным, что персонаж будет невидим до начала анимации.

Теперь давайте зададим ключевые кадры для анимации второго персонажа:

@keyframes move-dude-two {
    /* Персонаж невидим */
    0%, 42% {
        transform: translate3D(610px, 40px, 10px) rotateY(15deg);
        opacity: 0;
    }
    /* Появляется! */
    42.5% {
        transform: translate3D(610px, 40px, 10px) rotateY(15deg);
        display: block;
        opacity: 1;
    }
    /* Двигается по платформе */
    46%, 75% {
        transform: translate3D(610px, 40px, 120px) rotateY(15deg);
        opacity: 1;
    }
    /* Стоит на месте */
    76%, 97% {
        transform: translate3D(610px, -10px, 120px) rotateY(15deg) scaleY(2);
        opacity: 0;
    }
    /* Улетает на небо! */
    98%, 100% {
        transform: translate3D(610px, -10px, 120px) rotateY(15deg) scaleY(2);
        opacity: 0;
    }
}

@keyframes arms {
    /* Без рук */
    0%, 53% {
        opacity: 0;
    }
    /* С руками */
    54%, 100% {
        opacity: 1;
    }
}

Как и планировалось, анимация второго персонажа начинается на 42% общего времени анимации. Персонаж появляется из портала, стоит на месте и затем улетает. Второй набор ключевых кадров описывает анимацию рук. Изначально они невидимы и появляются во второй половине анимации.

Мы применим эту анимацию ко второму персонажу следующим образом:

.dude.two {
    animation: move-dude-two 10s linear infinite;
    opacity: 0;
}

.dude.two figure.arm {
    animation: arms 10s linear infinite;
    opacity: 0;
}

Мы применили две анимации ко второму персонажу. Так как обе длятся на протяжении 10 секунд и повторяются бесконечное количество раз, то они отлично стыкуются с таймингом анимации первого персонажа.

Посмотрите законченный результат, если вы еще этого не сделали, в современном браузере, и, желательно, что бы это не был Internet Explorer.

Недоработки и особенности браузеров

Что касается браузеров, стоит заметить, что в Internet Explorer ничего работать не будет. Firefox отображает сцену немного топорно, но не плохо, Safari отображает ее практически идеально (ждем, когда Apple наконец-то пропатчит webkit), и Chrome отображает ее настолько хорошо, насколько это возможно. Эй, браузеры вообще не для этого предназначены.

Производительность на разных устройствах неплохая (с учетом нагрузки, которую создают сами браузеры). Я тестировал демо на iPhone с Safari, и оно отображалось лучше, чем в Chrome на ноутбуке. Причина в том, что мы использовали в CSS 3D-трансформации, что привело к включению рендеринга с непосредственным использованием видеокарты.

Демо и контактная информация

Вы можете посмотреть демо онлайн или получить исходники на Github.

Я буду рад, если вы поделитесь своими мыслями по поводу этой статьи. Со мной можно связаться, написав мне письмо или в Twitter.