Голограммы, пленочные засветки и шейдеры на чистом CSS

Перевод статьи Holograms, light-leaks and how to build CSS-only shaders с сайта robbowen.digital для css-live.ru, автор — Робб Оуэн

Может, я чуть преуменьшаю, но WebGL — это нечто. За пять минут на любом из сайтов, отмечающих лучшие примеры веб-дизайна наградами, можно увидеть, как сайты один за другим полностью полагаются на мощь canvas. Инструменты вроде threejs привносят в браузер мощь 3D и GLSL-шейдеров, а с ними — совершенно новый уровень визуальных эффектов.

Но тут я задумался: почему всё веселье должно доставаться JS? Сейчас, когда браузеры наконец широко поддерживают mix-blend-mode, многие из типовых приемов шейдинга стали доступны и в CSS. С правильным подбором картинок и тщательным их наложением можно создавать удивительно качественные эффекты без нужды в каких-либо JS-зависимостях.

Взглянем на пример. По мере скроллинга картинки ниже солнечный свет вспыхивает тёплым оранжевым, прежде чем уйти в холодную голубизну. На миг вы увидите еще и размытые блики объектива (боке).

Асакуса на закате

Ух какой блеск. Разберем, как это устроено.

Что такое CSS-«шейдер»?

Шейдеры в мире WebGL — это сложные GLSL-скрипты, определяющие, как отображать на экране каждый отдельный пиксель. В CSS у нас пока нет такой степени контроля, поэтому на базовом уровне наш CSS-«шейдер» — это просто картинка с дополнительными слоями фона поверх нее. Да, я чуть вольно обошелся с названием, но при аккуратном использовании градиентов, масок, вложенности и mix-blend-mode можно управлять тем, как эти слои взаимодействуют между собой и с картинкой под ними.

Ради визуализации этой основной структуры пример «шейдера» выше составлен из нескольких вложенных дивов:

<div class="shader"> <img src="tower.jpg" alt="Асакуса на закате"> <div class="shader__layer specular"> <div class="shader__layer mask"></div> </div>
</div>

Чтобы каждый слой был выровнен с лежащей в основе картинкой, вложенный контент позиционирован с помощью такого CSS:

.shader { position: relative; overflow: hidden; backface-visibility: hidden; /* чтобы подключить мощь GPU. Подробности далее */
} .shader__layer { background: black; position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-size: 100%;
}

Хорошо, разобравшись с базовой раскладкой, давайте рассмотрим первый слой нашего эффекта — освещение.

Имитация отражения

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

.specular { background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

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

К счастью, есть старинное свойство из CSS 1 уровня, которое может нам помочь: если задать background-attachment: fixed для .specular, это значит, что при скроллинге страницы градиент останется неподвижен относительно вьюпорта. Это не только придает немного необходимого движения нашему шейдеру, но еще и означает, что мы можем в первом приближении имитировать изменение угла зрения без помощи JavaScript.

.specular { background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

Отлично! Теперь нужно применить это освещение к основной картинке.

Знай свои режимы наложения

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

Ниже вы видите примеры изображений, которые будем использовать для этих примеров. Слева — верхний слой, который накладывается, справа — базовая картинка, на которую он накладывается.

Черные и белые концентрические круги, с двумя серыми полукругами в центре
Озеро с высоты

Для начала взглянем на режим multiply. Multiply берет цвет каждого пикселя в текущем слое и умножает его на цвет пикселя прямо под ним. На практике это означает, что темные цвета в текущем слое затеняют те, что в слое под ним:

Озеро с высоты

Черные и белые концентрические круги, с двумя серыми полукругами в центре

При режиме наложения screen берется инверсия («негатив») каждого пикселя, они перемножаются и результат инвертируется снова. Это может звучать сложно, но можно думать о screen как о противоположности multiplyтемные цвета становятся прозрачными и только светлые цвета проявляются на фоне нижнего слоя:

Озеро с высоты

Черные и белые концентрические круги, с двумя серыми полукругами в центре

Наконец, color-dodge и color-burn — это как multiply и screen, доведенные до крайности. В обоих режимах цвет пикселя слоя-основы делится на цвет пикселя текущего слоя.

Для color-dodge это означает, что светлые и средние «выкручиваются на максимум», а темные тона остаются нетронутыми, а color-burn усиливает тени и более темные средние тона, а светлые тона остаются нетронутыми.

На картинке ниже левый пример — это color-burn, а правый — color-dodge.

Композитинг слоев

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

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

Вы спросите, как это можно сделать в CSS, если mix-blend-mode действует только на пиксели слоя прямо под ним, и можно задавать только один режим наложения за раз. Здесь-то и проявляется весь блеск нашей структуры HTML.

<div class="shader"> <img src="tower.jpg" alt="Асакуса на закате"> <div class="shader__layer specular"> <div class="shader__layer mask"></div> </div>
</div>

Вкладывая один див в другой, мы можем применять дополнительные mix-blend-mode к каждому диву-обертке, начиная с внутреннего дива. По сути это позволяет нам применять еще один mix-blend-mode к результату предыдущего наложения. Такой процесс послойного наложения разных режимов для получения конечного результата называется композитингом.

Давайте попробуем. Возьмем подходящую темную фоновую картинку и зададим mix-blend-mode: multiply для слоя .mask, тем самым отбросив те части градиента, через которые свет не должен просвечивать:

Две картинки объединяются в режиме multiply, образуя маску

.mask { mix-blend-mode: multiply; background-image: url(/tower_spec.jpg);
} .specular { background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

Теперь, с этой картой отражения, мы можем применить итоговое освещение к основной картинке. Нам нужен один из режимов наложения, игнорирующий черные и темные тона. Это значит, что для слоя .specular нам надо будет задать mix-blend-mode: screen или mix-blend-mode: color-dodge. Сработает любой из них, но поскольку мы добиваемся вспышки светлых тонов, как от яркого солнца, возьмем color-dodge.

Смотрите:

Две картинки объединяются в режиме color-dodge, образуя эффект вспышки

.specular { mix-blend-mode: color-dodge; background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
}

Итоговый шейдер

На этом наш эффект готов! Вот HTML и CSS полностью:

<div class="shader"> <img src="tower.jpg" alt="Асакуса на закате"> <div class="shader__layer specular"> <div class="shader__layer mask"></div> </div>
</div> <style>
.shader { position: relative; overflow: hidden; backface-visibility: hidden; /* чтоб подключить мощь GPU */
} .shader__layer { background: black; position: absolute; left: 0; top: 0; width: 100%; height: 100%; background-size: 100%; background-position: center;
} .specular { mix-blend-mode: color-dodge; background-attachment: fixed; background-image: linear-gradient(180deg, black 20%, #3c5e6d 35%, #f4310e, #f58308 80%, black);
} .mask { mix-blend-mode: multiply; background-image: url(/tower_spec.jpg);
}
</style>

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

Асакуса на закате

Развивая идею

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

Полярное сияние

В этом примере повторение градиента на фоне и уменьшение его высоты (background-size-y) заставляет эффект освещения двигаться по экрану быстрее. При маскировке с помощью карты отражения это создает иллюзию полярного сияния, переливающегося поверх основной картинки. Яркая вспышка светлых оттенков, получаемая при color-dodge, тут дает неправильный эффект, так что mix-blend-mode: screen вместо него для слоя .specular поддерживает четкий контур полярного сияния.

Сосновый лес ночью

Засветка пленки

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

Озеро с соснами

Голограмма

Наложение слоёв внутри маски дает еще больше возможностей. Что будет, если мы добавим еще один слой с background-attachment: fixed?

В этом последнем примере в слой маски входят фоновая SVG-картинка и еще один черно-белый градиент в направлении, перпендикулярном градиенту из карты отражения в слое .specular. Задание для вложенного слоя маски значения color-burn заставляет ее высвечивать SVG на максимум, и получается эффект голограммы в двух направлениях. CSS действительно замечательный!

Приборная панель старинной машины

В заключение

Я уже говорил это, но не грех и повторить: работать с современным CSS — такое удовольствие, и я постоянно под впечатлением от уровня качества визуальных эффектов, которого с ним можно достичь. Но всё-таки пришло время упомянуть о потенциальной оборотной стороне. В зависимости от того, в каком браузере и на каком устройстве вы читаете эту статью, плавность скроллинга может просесть до нуля, а вентиляторы — пойти вразнос.

На момент написания статьи режимы наложения в браузерах еще весьма ресурсоёмки. Для более сложных эффектов с несколькими слоями композитинга это может существенно ударить по быстродействию. Добавьте к этому любую CSS-анимацию или переход (transition), и ему придется туго — особенно в Safari. После нескольких попыток я наконец смог немного восстановить быстродействие с помощью backface-visibility: hidden, но моим первым порывом было перенести рендеринг на GPU с помощью проверенного хака с transform: translateZ(0). К сожалению, из-за трансформации проявилась другая странность, о которой надо знать.

Из-за того, что у нас используется background-attachment: fixed, CSS-трансформации для шейдера могут вызвать странные побочные эффекты. В Chrome оно в целом работает, но градиенты могут смещаться в зависимости от применяемых трансформаций. Firefox же просто игнорирует фиксированное положение и градиенты в нем выглядят полностью статичными. Уверен, что есть способы это обойти, но они наверняка пойдут вразрез с идеей полностью обойтись без JS.

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

Пока CSS-фильтры и режимы наложения не станут производительнее (либо пока браузеры не научатся подключать GLSL-фильтры напрямую из CSS ?), лучше всего использовать их точечно и понемножку.

Авторы изображений

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

P.S. Это тоже может быть интересно: