Привет! Я хотел бы поделиться опытом написания шейдеров в Unity.
Ссылка https://habr.com/ru/post/427357/
Начнем с шейдера искажения пространства (Displacement/Refraction) в 2D, рассмотрим функционал, используемый для его написания (GrabPass, PerRendererData), а также уделим внимание проблемам, которые обязательно возникнут.
Информация пригодится тем, кто имеет общее представление о шейдерах и пробовал их создавать, но мало знаком с возможностями, которые предоставляет Unity, и не знает с какой стороны подступиться. Загляните, возможно, мой опыт поможет вам разобраться.
Вот такого результата мы хотим добиться.
Для начала создадим шейдер, который будет просто отрисовывать указанный спрайт. Он будет нашей основой для дальнейших манипуляций. Что-то будет в него добавляться, что-то наоборот удаляться. От стандартного “Sprites-Default” он будет отличаться отсутствием некоторых тегов и действий, которые не повлияют на результат.
Получившаяся заготовка.
Теперь наша задача — внести изменения в текущее изображение на экране, а для этого нам необходимо получить изображение. И в этом нам поможет проход GrabPass. Этот проход захватит изображение на экране в текстуру _GrabTexture. Текстура будет содержать только то, что было отрисовано до того, как наш объект, использующий этот шейдер, пошёл на отрисовку.
Кроме самой текстуры нам нужны координаты развертки, чтобы получить из нее цвет пикселя. Для этого в данные фрагментного шейдера добавим дополнительные текстурные координаты. Эти координаты не нормированы (значения не в диапазоне от 0 до 1) и описывают положение точки в пространстве камеры (проекции).
А в вершинном шейдере заполним их.
Для того, чтобы получить цвет из _GrabTexture, мы можем воспользоваться следующим методом, если используем не нормированные координаты
Но мы воспользуемся другим методом и нормируем координаты сами, использовав перспективное деление, т.е. разделив на w-компоненту все остальные.
Перспективное деление также можно выполнить в вершинном шейдере, а во фрагментный передавать уже подготовленные данные.
Допишем соответственно фрагментный шейдер.
Отключим указанный режима смешивания, т.к. теперь мы реализуем свой режим смешивания внутри фрагментного шейдера.
И посмотрим на результат работы GrabPass.
Кажется, что ничего не произошло, но это не так. Для наглядности внесём небольшой сдвиг, для этого к текстурным координатам мы прибавим значение переменной. Чтобы мы могли изменять переменную, добавим новое свойство _DisplacementPower.
И снова внесём изменения во фрагментный шейдер.
Оп хоп и результат! Картинка со сдвигом.
После успешного сдвига можно приступать к более сложному искажению. Используем заранее подготовленные текстуры, которые будут хранить силу смещения в указанной точке. Красный цвет для значение смещения по оси x, а зелёный по оси y.
Приступим. Добавим новое свойство для хранения текстуры.
И переменную.
Во фрагментном шейдере получим значения смещения из текстуры и добавим их к текстурным координатам.
Теперь, изменяя значения параметра _DisplacementPower, мы не просто смещаем исходное изображение, а искажаем его.
Сейчас на экране присутствует только искажение пространства, а спрайт, который мы показывали в самом начале, отсутствует. Вернем его на место. Для этого мы воспользуемся непростым смешиванием цветов. Возьмём что-нибудь другое, например, режим смешивания overlay. Формула его такова:
где S — исходное изображение, С — корректирующее, то есть наш спрайт, R — результат.
Перенесём эту формулу в наш шейдер.
Применение условных операторов в шейдере достаточно запутанная тема. Многое зависит от платформы и используемой API для графики. В некоторых случаях условные операторы не повлияют на производительность. Но всегда стоит иметь запасной вариант. Заменить условный оператор можно с помощью математики и имеющихся методов. Воспользуемся следующей конструкцией
Перепишем получение цвета для режима overlay.
Обязательно нужно учесть прозрачность спрайта. Для этого мы воспользуемся линейной интерполяцией между двух цветов.
Полный код фрагментного шейдера.
И результат нашей работы.
Выше было упомянуто, что проход GrabPass {} захватывает содержимое экрана в текстуру _GrabTexture . При этом каждый раз, когда будет вызываться данный проход — содержимое текстуры будет обновляться.
Постоянного обновления можно избежать, если указать имя текстуры, в которую будет захватываться содержимое экрана.
Теперь содержимое текстуры обновиться только при первом вызове прохода GrabPass за кадр. Это экономит ресурсы, если объектов, использующих GrabPass{}много. Но если два объекта будут накладываться друг на друга, то будут заметны артефакты, так как оба объекта будут использовать одно и тоже изображение.
С использованием GrabPass{"_DisplacementGrabTexture"}.
С использованием GrabPass{}.
Теперь пора анимировать наш эффект. Мы хотим плавно уменьшать силу искажения по мере разрастания взрывной волны, имитируя её угасание. Для этого нам понадобится изменять свойства материала.
Результат анимации.
Обратим внимание на строку ниже.
Здесь мы не простой меняем одно из свойств материала, а создаём копию исходного материала (только при первом вызове этого метода) и работаем уже с ней. Вполне рабочий вариант, но если на сцене будет больше одного объекта, например тысяча, то создание стольких копий не приведёт ни к чему хорошему. Есть вариант лучше — это использование в шейдер атрибута [PerRendererData], а в скрипте объекта MaterialPropertyBlock.
Для этого в шейдере добавим атрибут свойству _DisplacementPower.
После этого свойство перестанет отображаться в инспекторе, т.к. теперь оно индивидуально для каждого объекта, которые и будут устанавливать значения.
Возвращаемся к скрипту и внесём в него изменения.
Теперь, чтобы менять свойство, мы будем обновлять MaterialPropertyBlock у нашего объекта, не создавая копий материала.
Получить MaterialPropertyBlock можно почти у всех компонентов, связанных с рендером. Например, у SpriteRenderer, ParticleRenderer, MeshRenderer и остальных компонентов Renderer. Но всегда найдётся исключение, это CanvasRenderer. Получить и изменить свойства таким методом у него невозможно. Поэтому, если вы будете писать 2D игру с использованием UI-компонентов, то столкнетесь с этой проблемой при написании шейдеров.
Неприятный эффект возникает при вращении изображения. На примере круглой волны это особенно заметно.
Правая волна при повороте (90 градусов) дает другое искажение.
Красным указаны вектора, получаемые из одной и той же точки текстуры, но при разном повороте этой текстуры. Значение смещения остаётся тем же и не учитывает поворот.
Для решения этой проблемы мы воспользуемся матрицей преобразования unity_ObjectToWorld. Она поможет пересчитать наш вектор из локальных координат в мировые.
Но матрица содержит в себе данные и о масштабе объекта, поэтому при указании силы искажения мы должны учитывать масштаб самого объекта.
Правая волна все также повернута на 90 градусов, но искажения теперь расчитываются верно.
Наша текстура имеет достаточно прозрачных пикселей (особенно, если мы используем тип меша Rect). Шейдер обрабатывает их, что в данном случае не имеет смысла. Поэтому попытаемся уменьшить количество лишних вычислений. Обработку прозрачных пикселей мы можем прервать при помощи метода clip(х). Если переданный ей параметр меньше нуля, то работа шейдера завершится. Но так как значение альфа не может быть меньше 0, то мы вычтем из него небольшое значение. Его так же можно вынести в свойства (Cutout) и использовать для отсечения прозрачных частей изображения. В данном случае отдельный параметр нам не нужен, поэтому мы будем использовать просто число 0,01.
Полный код фрагментного шейдера.
P.S.: Исходный код шейдера и скрипта — ссылка на git. В проекте также есть небольшой генератор текстур для искажения. Кристалл с постаментом был взят из ассета — 2D Game Kit.
Ссылка https://habr.com/ru/post/427357/
Начнем с шейдера искажения пространства (Displacement/Refraction) в 2D, рассмотрим функционал, используемый для его написания (GrabPass, PerRendererData), а также уделим внимание проблемам, которые обязательно возникнут.
Информация пригодится тем, кто имеет общее представление о шейдерах и пробовал их создавать, но мало знаком с возможностями, которые предоставляет Unity, и не знает с какой стороны подступиться. Загляните, возможно, мой опыт поможет вам разобраться.
Вот такого результата мы хотим добиться.
Подготовка
Для начала создадим шейдер, который будет просто отрисовывать указанный спрайт. Он будет нашей основой для дальнейших манипуляций. Что-то будет в него добавляться, что-то наоборот удаляться. От стандартного “Sprites-Default” он будет отличаться отсутствием некоторых тегов и действий, которые не повлияют на результат.
Код шейдера для отрисовки спрайта
Спрайт для отображения
Получившаяся заготовка.
GrabPass
Теперь наша задача — внести изменения в текущее изображение на экране, а для этого нам необходимо получить изображение. И в этом нам поможет проход GrabPass. Этот проход захватит изображение на экране в текстуру _GrabTexture. Текстура будет содержать только то, что было отрисовано до того, как наш объект, использующий этот шейдер, пошёл на отрисовку.
Кроме самой текстуры нам нужны координаты развертки, чтобы получить из нее цвет пикселя. Для этого в данные фрагментного шейдера добавим дополнительные текстурные координаты. Эти координаты не нормированы (значения не в диапазоне от 0 до 1) и описывают положение точки в пространстве камеры (проекции).
А в вершинном шейдере заполним их.
Для того, чтобы получить цвет из _GrabTexture, мы можем воспользоваться следующим методом, если используем не нормированные координаты
Но мы воспользуемся другим методом и нормируем координаты сами, использовав перспективное деление, т.е. разделив на w-компоненту все остальные.
w-компонента
Перспективное деление также можно выполнить в вершинном шейдере, а во фрагментный передавать уже подготовленные данные.
Допишем соответственно фрагментный шейдер.
Отключим указанный режима смешивания, т.к. теперь мы реализуем свой режим смешивания внутри фрагментного шейдера.
И посмотрим на результат работы GrabPass.
Кажется, что ничего не произошло, но это не так. Для наглядности внесём небольшой сдвиг, для этого к текстурным координатам мы прибавим значение переменной. Чтобы мы могли изменять переменную, добавим новое свойство _DisplacementPower.
И снова внесём изменения во фрагментный шейдер.
Оп хоп и результат! Картинка со сдвигом.
После успешного сдвига можно приступать к более сложному искажению. Используем заранее подготовленные текстуры, которые будут хранить силу смещения в указанной точке. Красный цвет для значение смещения по оси x, а зелёный по оси y.
Текстуры,используемые для искажения
Приступим. Добавим новое свойство для хранения текстуры.
И переменную.
Во фрагментном шейдере получим значения смещения из текстуры и добавим их к текстурным координатам.
Теперь, изменяя значения параметра _DisplacementPower, мы не просто смещаем исходное изображение, а искажаем его.
Overlay
Сейчас на экране присутствует только искажение пространства, а спрайт, который мы показывали в самом начале, отсутствует. Вернем его на место. Для этого мы воспользуемся непростым смешиванием цветов. Возьмём что-нибудь другое, например, режим смешивания overlay. Формула его такова:
где S — исходное изображение, С — корректирующее, то есть наш спрайт, R — результат.
Перенесём эту формулу в наш шейдер.
Применение условных операторов в шейдере достаточно запутанная тема. Многое зависит от платформы и используемой API для графики. В некоторых случаях условные операторы не повлияют на производительность. Но всегда стоит иметь запасной вариант. Заменить условный оператор можно с помощью математики и имеющихся методов. Воспользуемся следующей конструкцией
Функция step
Перепишем получение цвета для режима overlay.
Обязательно нужно учесть прозрачность спрайта. Для этого мы воспользуемся линейной интерполяцией между двух цветов.
Полный код фрагментного шейдера.
И результат нашей работы.
Особенность GrabPass
Выше было упомянуто, что проход GrabPass {} захватывает содержимое экрана в текстуру _GrabTexture . При этом каждый раз, когда будет вызываться данный проход — содержимое текстуры будет обновляться.
Постоянного обновления можно избежать, если указать имя текстуры, в которую будет захватываться содержимое экрана.
Теперь содержимое текстуры обновиться только при первом вызове прохода GrabPass за кадр. Это экономит ресурсы, если объектов, использующих GrabPass{}много. Но если два объекта будут накладываться друг на друга, то будут заметны артефакты, так как оба объекта будут использовать одно и тоже изображение.
С использованием GrabPass{"_DisplacementGrabTexture"}.
С использованием GrabPass{}.
Анимация
Теперь пора анимировать наш эффект. Мы хотим плавно уменьшать силу искажения по мере разрастания взрывной волны, имитируя её угасание. Для этого нам понадобится изменять свойства материала.
Скрипт для анимации
И его настройки
Результат анимации.
PerRendererData
Обратим внимание на строку ниже.
Здесь мы не простой меняем одно из свойств материала, а создаём копию исходного материала (только при первом вызове этого метода) и работаем уже с ней. Вполне рабочий вариант, но если на сцене будет больше одного объекта, например тысяча, то создание стольких копий не приведёт ни к чему хорошему. Есть вариант лучше — это использование в шейдер атрибута [PerRendererData], а в скрипте объекта MaterialPropertyBlock.
Для этого в шейдере добавим атрибут свойству _DisplacementPower.
После этого свойство перестанет отображаться в инспекторе, т.к. теперь оно индивидуально для каждого объекта, которые и будут устанавливать значения.
Возвращаемся к скрипту и внесём в него изменения.
Теперь, чтобы менять свойство, мы будем обновлять MaterialPropertyBlock у нашего объекта, не создавая копий материала.
О SpriteRenderer
Особенность PerRendererData
Получить MaterialPropertyBlock можно почти у всех компонентов, связанных с рендером. Например, у SpriteRenderer, ParticleRenderer, MeshRenderer и остальных компонентов Renderer. Но всегда найдётся исключение, это CanvasRenderer. Получить и изменить свойства таким методом у него невозможно. Поэтому, если вы будете писать 2D игру с использованием UI-компонентов, то столкнетесь с этой проблемой при написании шейдеров.
Вращение
Неприятный эффект возникает при вращении изображения. На примере круглой волны это особенно заметно.
Правая волна при повороте (90 градусов) дает другое искажение.
Красным указаны вектора, получаемые из одной и той же точки текстуры, но при разном повороте этой текстуры. Значение смещения остаётся тем же и не учитывает поворот.
Для решения этой проблемы мы воспользуемся матрицей преобразования unity_ObjectToWorld. Она поможет пересчитать наш вектор из локальных координат в мировые.
Но матрица содержит в себе данные и о масштабе объекта, поэтому при указании силы искажения мы должны учитывать масштаб самого объекта.
Правая волна все также повернута на 90 градусов, но искажения теперь расчитываются верно.
Clip
Наша текстура имеет достаточно прозрачных пикселей (особенно, если мы используем тип меша Rect). Шейдер обрабатывает их, что в данном случае не имеет смысла. Поэтому попытаемся уменьшить количество лишних вычислений. Обработку прозрачных пикселей мы можем прервать при помощи метода clip(х). Если переданный ей параметр меньше нуля, то работа шейдера завершится. Но так как значение альфа не может быть меньше 0, то мы вычтем из него небольшое значение. Его так же можно вынести в свойства (Cutout) и использовать для отсечения прозрачных частей изображения. В данном случае отдельный параметр нам не нужен, поэтому мы будем использовать просто число 0,01.
Полный код фрагментного шейдера.
P.S.: Исходный код шейдера и скрипта — ссылка на git. В проекте также есть небольшой генератор текстур для искажения. Кристалл с постаментом был взят из ассета — 2D Game Kit.
Комментариев нет:
Отправить комментарий