В Universal Render Pipeline, создавая свои RendererFeature, можно легко расширить возможности отрисовки. Добавление новых проходов в конвеер рендеринга позволяет создавать различные эффекты. В этой статье, используя ScriptableRendererFeature и ScriptableRenderPass, создадим эффект обводки объекта (Outline) и рассмотрим некоторые особенности его реализации.
Scriptable Render Pipeline позволяет управлять отрисовкой графики посредством скриптов на C# и контролировать порядок обработки объектов, света, теней и прочего. Universal Render Pipeline — это готовый Scriptable Render Pipeline, разработанный Unity и предназначенный на замену старому встроенному RP.
Возможности Universal RP можно расширить, создавая и добавляя свои проходы отрисовки ( ScriptableRendererFeature и ScriptableRenderPass ). Об этом и будет текущая статья. Она пригодится тем, кто собирается переходить на Universal RP и, возможно, поможет лучше понимать работу имеющихся ScriptableRenderPass’ов в Universal RP.
При написании этой статьи использовалась Unity 2019.3 и Universal RP 7.1.8.
Мы будем разбираться в работе ScriptableRendererFeature и ScriptableRenderPass на примере создания эффекта обводки непрозрачных объектов.
Для этого создадим ScriptableRendererFeature, выполняющую следующие действия:
Исходный кадр —
И последовательность результатов, которые мы должны достичь:
В ходе работы мы создадим шейдер, в глобальные свойства которого будут сохраняться результаты первого и второго проходов. Последний проход отобразит результат работы самого шейдера на экран.
ScriptableRendererFeature используется для добавления своих проходов отрисовки (ScriptableRenderPass) в Universal RP. Создадим класс OutlineFeature, наследуемый от ScriptableRenderFeature и реализуем его методы.
Метод Create() служит для создания и настройки проходов. А метод AddRenderPasses() для внедрения созданных проходов в очередь отрисовки.
Теперь приступим к созданию проходов отрисовки, а к текущему классу будем возвращаться после реализации каждого из них.
Задача этого прохода — отрисовывать объекты из определенного слоя с заменой материала в глобальное свойство-текстуру шейдера. Это будет упрощенная версия имеющегося в Universal RP прохода RenderObjectsPass, с единственным отличием в цели ( RenderTarget ), куда будет производится отрисовка.
Создадим класс MyRenderObjectsPass, наследуемый от ScriptableRenderPass. Реализуем метод Execute(), который будет содержать всю логику работы прохода, а так же переопределим метод Configure().
Метод Configure() используется для указания цели рендеринга и создания временных текстур. По умолчанию целью является цель текущей камеры и после выполнения прохода она вновь будет указана по умолчанию. Вызов этого метода осуществляется перед выполнение основой логики прохода.
Объявим RenderTargetHandle для новой цели рендеринга. Используя его, создадим временную текстуру и укажем её как цель. RenderTargetHandle содержит в себе идентификатор используемой временной RenderTexture. А также позволяет получить RenderTargetIdentifier, служащий для идентификации цели рендеринга, которая может быть задана, например как объект RenderTexture, Texture, временная RenderTexture или встроенная (используется камерой при отрисовке кадра).
Объект RenderTargetHandle будет создаваться в OutlineFeature и передаваться нашему проходу при его создании.
Метод GetTemporaryRT() создаёт временную RenderTexture с заданными параметрами и устанавливает её как глобальное свойство шейдера с указанным именем (имя будет задаваться в фиче).
Для создания временной RenderTexture используем дескриптор текущей камеры, содержащий информацию о размере, формате и прочих параметрах цели камеры.
Указание цели и её очистка должны происходить только в Configure() с использованием методов ConfigureTarget() и ClearTarget().
Подробно рассматривать отрисовку не будем, т.к. это может увести нас далеко и надолго от основной темы. Для отрисовки воспользуемся методом ScriptableRenderContext.DrawRenderers(). Создадим настройки для отрисовки только непрозрачных объектов только из указанных слоёв. Маску слоя будем передавать в конструктор.
Переопределим используемые материалы при отрисовке, так как нам нужны только контуры объектов.
Создадим в ShaderGraph шейдер материала, который будет использоваться при отрисовке объектов в текущем проходе.
Вернемся в OutlieFeature. Для начала создадим класс для настроек нашего прохода.
Объявим поля для настроек MyRenderPass и имени глобального свойства-текстуры, используемой в качестве цели рендеринга нашим проходом.
Создадим идентификатор для свойства-текстуры и экземпляр MyRenderPass.
В методе AddRendererPass добавляем наш проход в очередь на исполнение.
Результат прохода для исходной сцены должен получиться следующий:
Цель этого прохода — размыть изображение, полученное на предыдущем шаге и установить его в глобальное свойство шейдера.
Для этого несколько раз будем копировать исходную текстуру во временные, с применением к ней шейдера размытия. При этом исходное изображение можно уменьшить в размерах (создать уменьшенную копию), что ускорит расчеты и не повлияет на качестве результата.
Создадим класс BlurPass, наследуемый от ScriptableRenderPass.
Заведём переменные под исходную, целевую и временные текстуры (и их ID).
Все ID для RenderTexture задаются через Shader.PropertyID(). Это не означает что где-то обязательно должны существовать такие свойства шейдера.
Добавим поля и под остальные параметры, которые сразу же инициализируем в конструкторе.
_blurMaterial — материал с шейдером размытия.
_downSample — коэффициент для уменьшения размера текстуры
_passesCount — количество проходов размытия, которое будет применено.
Для создания временных текстур создадим дескриптор со всей необходимой информацией о ней — размере, формате и прочем. Высоту и размер будем масштабировать относительно дескриптора камеры.
Мы снова меняем цель рендеринга, поэтому создадим ещё одну временную текстуру и укажем её как цель.
Некоторые задачи рендеринга могут быть выполнены с помощью специальных методов ScriptableRenderContext, которые настраивают и добавляют в него команды. Для выполнения других команд потребуется использовать CommandBuffer, который можно получить из пула.
После добавления команд и отправки их на выполнение контексту, буфер нужно будет вернуть обратно в пул.
Конечная реализация метода Execute() будет следующей.
Для размытия создадим простой шейдер, который будет вычислять цвет пикселя с учётом его ближайших соседей ( среднее значение цвета пяти пикселей ).
Порядок действия будет аналогичен добавлению нашего первого прохода. Сначала создадим настройки.
Затем поля.
И добавим в очередь на выполнение.
Результат прохода:
Конечное изображение с обводкой объектов будет получено с помощью шейдера. И результат его работы будет отображаться поверх текущего изображения на экране.
Ниже представлен сразу весь код прохода, т.к. вся логика заключена в двух строках.
RenderingUtils.fullscreenMesh возвращает меш размером 1 на 1.
Создадим шейдер для получения контура. Он должен содержать два глобальных свойства-текстуры. _OutlineRenderTexture и _OutlineBluredTexture для изображения указанных объектов и его размытого варианта.
Результат работы шейдера для для двух полученных ранее изображений:
Все действия аналогичны предыдущим проходам.
Осталось указать когда будут вызываться созданные проходы. Для этого каждому из них нужно указать параметр renderPassEvent.
Создадим соответствующее поле в OutlineFeature.
И укажем его всем созданным проходам.
Добавим слой Outline и установим его для объектов, которые хотим обвести.
Создадим и настроим все необходимые ассеты: UniversalRendererPipelineAsset и ForwardRendererData.
Результат для нашего исходного кадра будет следующим!
Сейчас обводка объекта будет видна всегда, даже через другие объекты. Чтобы наш эффект учитывал глубину сцены нужно внести несколько изменений.
Указывая цель нашего рендера, мы должны также указать текущий буфер глубины. Создадим соответствующее поле и метод.
В OutlineFeature передадим MyRenderObjectsPass текущую глубину сцены.
В используемом UniversalRenderPipelineAsset поставим галочку напротив пункта DepthTexture.
Результат без учета глубины:
Результат с учетом глубины:
ScriptableRendererFeature достаточно удобный инструмент для добавления своих проходов в RP.
В нем можно легко заменять RenderObjectsPass’ы и использовать их в других ScriptableRendererFeature. Не нужно сильно углубляться в реализацию Universal RP и менять его код, чтобы что-то добавить.
Для того чтобы общий алгоритм работы со ScriptableRendererFeature и ScriptableRenderPass был более понятен, и чтобы статья не сильно разрослась, я намеренно старался создавать код проходов простым, пусть даже в ущерб их универсальности и оптимальности.
Исходный код — ссылка на gitlab
Модели и сцена взяты из игры Lander Missions: planet depths
За основу примера была взята следующая реализация обводки — ссылка на youtube
Примеры реализации собственных RenderFeature от Unity — ссылка на github.
Серия уроков по созданию собственного ScriptableRenderPipeline. После прочтения становится ясна общая логика работы RP и шейдеров — ссылка на туториалы.
Вступление или пару слов о Render Pipeline
Scriptable Render Pipeline позволяет управлять отрисовкой графики посредством скриптов на C# и контролировать порядок обработки объектов, света, теней и прочего. Universal Render Pipeline — это готовый Scriptable Render Pipeline, разработанный Unity и предназначенный на замену старому встроенному RP.
Возможности Universal RP можно расширить, создавая и добавляя свои проходы отрисовки ( ScriptableRendererFeature и ScriptableRenderPass ). Об этом и будет текущая статья. Она пригодится тем, кто собирается переходить на Universal RP и, возможно, поможет лучше понимать работу имеющихся ScriptableRenderPass’ов в Universal RP.
При написании этой статьи использовалась Unity 2019.3 и Universal RP 7.1.8.
План действий
Мы будем разбираться в работе ScriptableRendererFeature и ScriptableRenderPass на примере создания эффекта обводки непрозрачных объектов.
Для этого создадим ScriptableRendererFeature, выполняющую следующие действия:
- отрисовка указанных объектов
- размытие отрисованных объектов
- получение контуров объектов из изображений, полученных на предыдущих проходах
Исходный кадр —
И последовательность результатов, которые мы должны достичь:
В ходе работы мы создадим шейдер, в глобальные свойства которого будут сохраняться результаты первого и второго проходов. Последний проход отобразит результат работы самого шейдера на экран.
Глобальные свойства
Это свойства, объявленные в шейдере, но не имеющие определения в блоке Properties. В данном примере нет принципиальной разницы как мы будем задавать текстуры — через глобальные или обычные свойства.
Основная причина использования глобальных свойств — нет необходимости передавать материал каждому проходу. А также отладка становится немного удобнее.
Основная причина использования глобальных свойств — нет необходимости передавать материал каждому проходу. А также отладка становится немного удобнее.
Создаём OutlineFeature
ScriptableRendererFeature используется для добавления своих проходов отрисовки (ScriptableRenderPass) в Universal RP. Создадим класс OutlineFeature, наследуемый от ScriptableRenderFeature и реализуем его методы.
using UnityEngine;
using UnityEngine.Rendering.Universal;
public class OutlineFeature : ScriptableRendererFeature
{
public override void Create()
{ }
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{ }
}
Метод Create() служит для создания и настройки проходов. А метод AddRenderPasses() для внедрения созданных проходов в очередь отрисовки.
аргументы AddRenderPasses()
ScriptableRenderer — текущая стратегия рендеринга по умолчанию для Universal RP. На данный момент в Universal RP реализована только стратегия Forward Rendering.
RenderingData содержит данные для настройки проходов отрисовки — данные об отбраковке, камерах, освещении и прочем.
RenderingData содержит данные для настройки проходов отрисовки — данные об отбраковке, камерах, освещении и прочем.
Теперь приступим к созданию проходов отрисовки, а к текущему классу будем возвращаться после реализации каждого из них.
Render Objects Pass
Задача этого прохода — отрисовывать объекты из определенного слоя с заменой материала в глобальное свойство-текстуру шейдера. Это будет упрощенная версия имеющегося в Universal RP прохода RenderObjectsPass, с единственным отличием в цели ( RenderTarget ), куда будет производится отрисовка.
Создадим класс MyRenderObjectsPass, наследуемый от ScriptableRenderPass. Реализуем метод Execute(), который будет содержать всю логику работы прохода, а так же переопределим метод Configure().
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class MyRenderObjectsPass : ScriptableRenderPass
{
{
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{ }
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{ }
}
}
Метод Configure() используется для указания цели рендеринга и создания временных текстур. По умолчанию целью является цель текущей камеры и после выполнения прохода она вновь будет указана по умолчанию. Вызов этого метода осуществляется перед выполнение основой логики прохода.
Замена цели рендеринга
Объявим RenderTargetHandle для новой цели рендеринга. Используя его, создадим временную текстуру и укажем её как цель. RenderTargetHandle содержит в себе идентификатор используемой временной RenderTexture. А также позволяет получить RenderTargetIdentifier, служащий для идентификации цели рендеринга, которая может быть задана, например как объект RenderTexture, Texture, временная RenderTexture или встроенная (используется камерой при отрисовке кадра).
Объект RenderTargetHandle будет создаваться в OutlineFeature и передаваться нашему проходу при его создании.
private RenderTargetHandle _destination;
public MyRenderObjectsPass(RenderTargetHandle destination)
{
_destination = destination;
}
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
cmd.GetTemporaryRT(_destination.id, cameraTextureDescriptor);
}
Метод GetTemporaryRT() создаёт временную RenderTexture с заданными параметрами и устанавливает её как глобальное свойство шейдера с указанным именем (имя будет задаваться в фиче).
Удаление RenderTexture
ReleaseTemporaryRT() удаляет указанную временную RenderTexture. Это можно сделать в конце метода Execute() или в переопределенном методе FrameCleanup.
После завершения рендеринга, все временные RenderTexture, которые не были освобождены явно, будут удалены.
После завершения рендеринга, все временные RenderTexture, которые не были освобождены явно, будут удалены.
Для создания временной RenderTexture используем дескриптор текущей камеры, содержащий информацию о размере, формате и прочих параметрах цели камеры.
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
cmd.GetTemporaryRT(_destination.id, cameraTextureDescriptor);
ConfigureTarget(_destination.Identifier());
ConfigureClear(ClearFlag.All, Color.clear);
}
Указание цели и её очистка должны происходить только в Configure() с использованием методов ConfigureTarget() и ClearTarget().
Рендер
Подробно рассматривать отрисовку не будем, т.к. это может увести нас далеко и надолго от основной темы. Для отрисовки воспользуемся методом ScriptableRenderContext.DrawRenderers(). Создадим настройки для отрисовки только непрозрачных объектов только из указанных слоёв. Маску слоя будем передавать в конструктор.
...
private List<ShaderTagId> _shaderTagIdList = new List<ShaderTagId>() { new ShaderTagId("UniversalForward") };
private FilteringSettings _filteringSettings;
private RenderStateBlock _renderStateBlock;
...
public MyRenderObjectsPass(RenderTargetHandle destination, int layerMask)
{
_destination = destination;
_filteringSettings = new FilteringSettings(RenderQueueRange.opaque, layerMask);
_renderStateBlock = new RenderStateBlock(RenderStateMask.Nothing);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
SortingCriteria sortingCriteria = renderingData.cameraData.defaultOpaqueSortFlags;
DrawingSettings drawingSettings = CreateDrawingSettings(_shaderTagIdList, ref renderingData, sortingCriteria);
context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref _filteringSettings, ref _renderStateBlock);
}
Очень кратко о параметрах
CullingResults — результаты отбраковки (берём из RenderingData)
FilteringSettings — данные о слоях и типе объектов.
DrawingSettings — настройки отрисовки.
RenderStateBlock — набор значений, используемых для переопределения состояния визуализации (глубины, режимов смешивания и т.п.)
FilteringSettings — данные о слоях и типе объектов.
DrawingSettings — настройки отрисовки.
RenderStateBlock — набор значений, используемых для переопределения состояния визуализации (глубины, режимов смешивания и т.п.)
О готовом RenderObjectsPass в UniversalRP
Данный метод не универсален, т.к содержит много захардкоженных данных. RenderObjectsPass, реализованный в Universal RP, позволяет более гибко настраивать рендер объектов. Но для примера реализации своей составной RenderFeature практичней использовать что-то более простое :)
Замена материала
Переопределим используемые материалы при отрисовке, так как нам нужны только контуры объектов.
private Material _overrideMaterial;
public MyRenderObjectsPass(RenderTargetHandle destination, int layerMask,, Material overrideMaterial)
{
...
_overrideMaterial = overrideMaterial;
...
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
...
DrawingSettings drawingSettings = CreateDrawingSettings(_shaderTagIdList, ref renderingData, sortingCriteria);
drawingSettings.overrideMaterial = _overrideMaterial;
...
}
Шейдер для отрисовки
Создадим в ShaderGraph шейдер материала, который будет использоваться при отрисовке объектов в текущем проходе.
Добавляем проход в OutlineFeature
Вернемся в OutlieFeature. Для начала создадим класс для настроек нашего прохода.
public class OutlineFeature : ScriptableRendererFeature
{
[Serializable]
public class RenderSettings
{
public Material OverrideMaterial = null;
public LayerMask LayerMask = 0;
}
...
}
Объявим поля для настроек MyRenderPass и имени глобального свойства-текстуры, используемой в качестве цели рендеринга нашим проходом.
[SerializeField] private string _renderTextureName;
[SerializeField] private RenderSettings _renderSettings;
Создадим идентификатор для свойства-текстуры и экземпляр MyRenderPass.
private RenderTargetHandle _renderTexture;
private MyRenderObjectsPass _renderPass;
public override void Create()
{
_renderTexture.Init(_renderTextureName);
_renderPass = new MyRenderObjectsPass(_renderTexture, _renderSettings.LayerMask, _renderSettings.OverrideMaterial);
}
В методе AddRendererPass добавляем наш проход в очередь на исполнение.
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(_renderPass);
}
Забегая вперёд
Следует также указать когда должен выполняться проход. Но это об этом позже, после реализации всех проходов.
Результат прохода для исходной сцены должен получиться следующий:
Отладка
Для отладки удобно использовать Frame Debug для просмотра результатов каждого прохода (Windows-Analysis-FrameDebugger).
Blur Pass
Цель этого прохода — размыть изображение, полученное на предыдущем шаге и установить его в глобальное свойство шейдера.
Для этого несколько раз будем копировать исходную текстуру во временные, с применением к ней шейдера размытия. При этом исходное изображение можно уменьшить в размерах (создать уменьшенную копию), что ускорит расчеты и не повлияет на качестве результата.
Создадим класс BlurPass, наследуемый от ScriptableRenderPass.
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class BlurPass : ScriptableRenderPass
{
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{ }
}
Заведём переменные под исходную, целевую и временные текстуры (и их ID).
private int _tmpBlurRTId1 = Shader.PropertyToID("_TempBlurTexture1");
private int _tmpBlurRTId2 = Shader.PropertyToID("_TempBlurTexture2");
private RenderTargetIdentifier _tmpBlurRT1;
private RenderTargetIdentifier _tmpBlurRT2;
private RenderTargetIdentifier _source;
private RenderTargetHandle _destination;
Все ID для RenderTexture задаются через Shader.PropertyID(). Это не означает что где-то обязательно должны существовать такие свойства шейдера.
Добавим поля и под остальные параметры, которые сразу же инициализируем в конструкторе.
private int _passesCount;
private int _downSample;
private Material _blurMaterial;
public BlurPass(Material blurMaterial, int downSample, int passesCount)
{
_blurMaterial = blurMaterial;
_downSample = downSample;
_passesCount = passesCount;
}
_blurMaterial — материал с шейдером размытия.
_downSample — коэффициент для уменьшения размера текстуры
_passesCount — количество проходов размытия, которое будет применено.
Для создания временных текстур создадим дескриптор со всей необходимой информацией о ней — размере, формате и прочем. Высоту и размер будем масштабировать относительно дескриптора камеры.
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
var width = Mathf.Max(1, cameraTextureDescriptor.width >> _downSample);
var height = Mathf.Max(1, cameraTextureDescriptor.height >> _downSample);
var blurTextureDesc = new RenderTextureDescriptor(width, height, RenderTextureFormat.ARGB32, 0, 0);
Также создадим идентификаторы и сами временные RenderTexture. _tmpBlurRT1 = new RenderTargetIdentifier(_tmpBlurRTId1);
_tmpBlurRT2 = new RenderTargetIdentifier(_tmpBlurRTId2);
cmd.GetTemporaryRT(_tmpBlurRTId1, blurTextureDesc, FilterMode.Bilinear);
cmd.GetTemporaryRT(_tmpBlurRTId2, blurTextureDesc, FilterMode.Bilinear);
Мы снова меняем цель рендеринга, поэтому создадим ещё одну временную текстуру и укажем её как цель.
cmd.GetTemporaryRT(_destination.id, blurTextureDesc, FilterMode.Bilinear);
ConfigureTarget(_destination.Identifier());
}
Размытие
Некоторые задачи рендеринга могут быть выполнены с помощью специальных методов ScriptableRenderContext, которые настраивают и добавляют в него команды. Для выполнения других команд потребуется использовать CommandBuffer, который можно получить из пула.
После добавления команд и отправки их на выполнение контексту, буфер нужно будет вернуть обратно в пул.
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
var cmd = CommandBufferPool.Get("BlurPass");
...
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
Конечная реализация метода Execute() будет следующей.
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
var cmd = CommandBufferPool.Get("BlurPass");
if (_passesCount > 0)
{
cmd.Blit(_source, _tmpBlurRT1, _blurMaterial, 0);
for (int i = 0; i < _passesCount - 1; i++)
{
cmd.Blit(_tmpBlurRT1, _tmpBlurRT2, _blurMaterial, 0);
var t = _tmpBlurRT1;
_tmpBlurRT1 = _tmpBlurRT2;
_tmpBlurRT2 = t;
}
cmd.Blit(_tmpBlurRT1, _destination.Identifier());
}
else
cmd.Blit(_source, _destination.Identifier());
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
Blit
Команда Blit() копирует исходную текстуру в указанную с применением к ней шейдера.
Шейдер
Для размытия создадим простой шейдер, который будет вычислять цвет пикселя с учётом его ближайших соседей ( среднее значение цвета пяти пикселей ).
Шейдер размытия
Shader "Custom/Blur"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
TEXTURE2D_X(_MainTex);
SAMPLER(sampler_MainTex);
float4 _MainTex_TexelSize;
Varyings Vert(Attributes input)
{
Varyings output;
output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
output.uv = input.uv;
return output;
}
half4 Frag(Varyings input) : SV_Target
{
float2 offset = _MainTex_TexelSize.xy;
float2 uv = UnityStereoTransformScreenSpaceTex(input.uv);
half4 color = SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, input.uv);
color += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, input.uv + float2(-1, 1) * offset);
color += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, input.uv + float2( 1, 1) * offset);
color += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, input.uv + float2( 1,-1) * offset);
color += SAMPLE_TEXTURE2D_X(_MainTex, sampler_MainTex, input.uv + float2(-1,-1) * offset);
return color/5.0;
}
ENDHLSL
Pass
{
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
ENDHLSL
}
}
}
Почему не ShaderGraph?
Я считаю ShaderGraph однозначно хорошим инструментом, но не когда пытаешься создать граф на 13 дюймовом экране ноутбука используя тачпад :)
Добавляем проход в OutlineFeature
Порядок действия будет аналогичен добавлению нашего первого прохода. Сначала создадим настройки.
[Serializable]
public class BlurSettings
{
public Material BlurMaterial;
public int DownSample = 1;
public int PassesCount = 1;
}
Затем поля.
[SerializeField] private string _bluredTextureName;
[SerializeField] private BlurSettings _blurSettings;
private RenderTargetHandle _bluredTexture;
private BlurPass _blurPass;
...
public override void Create()
{
_bluredTexture.Init(_bluredTextureName);
_blurPass = new BlurPass(_blurSettings.BlurMaterial, _blurSettings.DownSample, _blurSettings.PassesCount);
}
И добавим в очередь на выполнение.
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(_renderPass);
renderer.EnqueuePass(_blurPass);
}
Результат прохода:
Outline Pass
Конечное изображение с обводкой объектов будет получено с помощью шейдера. И результат его работы будет отображаться поверх текущего изображения на экране.
Ниже представлен сразу весь код прохода, т.к. вся логика заключена в двух строках.
public class OutlinePass : ScriptableRenderPass
{
private string _profilerTag = "Outline";
private Material _material;
public OutlinePass(Material material)
{
_material = material;
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
var cmd = CommandBufferPool.Get(_profilerTag);
using (new ProfilingSample(cmd, _profilerTag))
{
var mesh = RenderingUtils.fullscreenMesh;
cmd.DrawMesh(mesh, Matrix4x4.identity, _material, 0, 0);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
}
RenderingUtils.fullscreenMesh возвращает меш размером 1 на 1.
Шейдер
Создадим шейдер для получения контура. Он должен содержать два глобальных свойства-текстуры. _OutlineRenderTexture и _OutlineBluredTexture для изображения указанных объектов и его размытого варианта.
код шейдера
Так как меш будет отображаться на весь экран, нам не нужно преобразовывать позиции вершин. В некоторых случаях Unity выполняет вертикальный переворот экрана. Чтобы определить это, шейдер проверяет значение _ProjectionParams.х.
Shader "Custom/Outline"
{
Properties
{
_Color ("Glow Color", Color ) = ( 1, 1, 1, 1)
_Intensity ("Intensity", Float) = 2
[Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 1
[Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
}
SubShader
{
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
half4 positionCS : SV_POSITION;
half2 uv : TEXCOORD0;
};
TEXTURE2D_X(_OutlineRenderTexture);
SAMPLER(sampler_OutlineRenderTexture);
TEXTURE2D_X(_OutlineBluredTexture);
SAMPLER(sampler_OutlineBluredTexture);
half4 _Color;
half _Intensity;
Varyings Vertex(Attributes input)
{
Varyings output;
output.positionCS = float4(input.positionOS.xy, 0.0, 1.0);
output.uv = input.uv;
if (_ProjectionParams.x < 0.0)
output.uv.y = 1.0 - output.uv.y;
return output;
}
half4 Fragment(Varyings input) : SV_Target
{
float2 uv = UnityStereoTransformScreenSpaceTex(input.uv);
half4 prepassColor = SAMPLE_TEXTURE2D_X(_OutlineRenderTexture, sampler_OutlineRenderTexture, uv);
half4 bluredColor = SAMPLE_TEXTURE2D_X(_OutlineBluredTexture, sampler_OutlineBluredTexture,uv);
half4 difColor = max( 0, bluredColor - prepassColor);
half4 color = difColor* _Color * _Intensity;
color.a = 1;
return color;
}
ENDHLSL
Pass
{
Blend [_SrcBlend] [_DstBlend]
ZTest Always // всегда рисуем, независимо от текущей глубины в буфере
ZWrite Off // и ничего в него не пишем
Cull Off // рисуем все стороны меша
HLSLPROGRAM
#pragma vertex Vertex
#pragma fragment Fragment
ENDHLSL
}
}
}
Так как меш будет отображаться на весь экран, нам не нужно преобразовывать позиции вершин. В некоторых случаях Unity выполняет вертикальный переворот экрана. Чтобы определить это, шейдер проверяет значение _ProjectionParams.х.
Результат работы шейдера для для двух полученных ранее изображений:
Добавляем проход в OutlineFeature
Все действия аналогичны предыдущим проходам.
[SerializeField] private Material _outlineMaterial;
private OutlinePass _outlinePass;
public override void Create()
{
...
_outlinePass = new OutlinePass(_outlineMaterial);
....
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(_renderPass);
renderer.EnqueuePass(_blurPass);
renderer.EnqueuePass(_outlinePass);
}
RenderPassEvent
Осталось указать когда будут вызываться созданные проходы. Для этого каждому из них нужно указать параметр renderPassEvent.
Список событий
Места в Universal RP, куда могут быть добавлены проходы.
BeforeRendering
BeforeRenderingShadows
AfterRenderingShadows
BeforeRenderingPrepasses
AfterRenderingPrePasses
BeforeRenderingOpaques
AfterRenderingOpaques
BeforeRenderingSkybox
AfterRenderingSkybox
BeforeRenderingTransparents
AfterRenderingTransparents
BeforeRenderingPostProcessing
AfterRenderingPostProcessing
AfterRendering
BeforeRendering
BeforeRenderingShadows
AfterRenderingShadows
BeforeRenderingPrepasses
AfterRenderingPrePasses
BeforeRenderingOpaques
AfterRenderingOpaques
BeforeRenderingSkybox
AfterRenderingSkybox
BeforeRenderingTransparents
AfterRenderingTransparents
BeforeRenderingPostProcessing
AfterRenderingPostProcessing
AfterRendering
Создадим соответствующее поле в OutlineFeature.
[SerializeField] private RenderPassEvent _renderPassEvent;
И укажем его всем созданным проходам.
public override void Create()
{
...
_renderPass.renderPassEvent = _renderPassEvent;
_blurPass.renderPassEvent = _renderPassEvent;
_outlinePass.renderPassEvent = _renderPassEvent;
}
Настройка
Добавим слой Outline и установим его для объектов, которые хотим обвести.
Создадим и настроим все необходимые ассеты: UniversalRendererPipelineAsset и ForwardRendererData.
Результат
Результат для нашего исходного кадра будет следующим!
Доработка
Сейчас обводка объекта будет видна всегда, даже через другие объекты. Чтобы наш эффект учитывал глубину сцены нужно внести несколько изменений.
RenderObjectsPass
Указывая цель нашего рендера, мы должны также указать текущий буфер глубины. Создадим соответствующее поле и метод.
public class MyRenderObjectsPass : ScriptableRenderPass
{
...
private RenderTargetIdentifier _depth;
public void SetDepthTexture(RenderTargetIdentifier depth)
{ _depth = depth; }
...
}
В методе Configure() укажем глубину в настройке цели рендера.
<source lang="cs">
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
cmd.GetTemporaryRT(_destination.id, cameraTextureDescriptor);
ConfigureTarget(_destination.Identifier(), _depth);
ConfigureClear(ClearFlag.Color, Color.clear);
}
OutlineFeature
В OutlineFeature передадим MyRenderObjectsPass текущую глубину сцены.
public class OutlineFeature : ScriptableRendererFeature
{
...
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
var depthTexture = renderer.cameraDepth;
_renderPass.SetDepthTexture(depthTexture);
renderer.EnqueuePass(_renderPass);
renderer.EnqueuePass(_blurPass);
renderer.EnqueuePass(_outlinePass);
}
...
}
UniversalRenderPipelineAsset
В используемом UniversalRenderPipelineAsset поставим галочку напротив пункта DepthTexture.
Результат
Результат без учета глубины:
Результат с учетом глубины:
Итог
ScriptableRendererFeature достаточно удобный инструмент для добавления своих проходов в RP.
В нем можно легко заменять RenderObjectsPass’ы и использовать их в других ScriptableRendererFeature. Не нужно сильно углубляться в реализацию Universal RP и менять его код, чтобы что-то добавить.
P.S.
Для того чтобы общий алгоритм работы со ScriptableRendererFeature и ScriptableRenderPass был более понятен, и чтобы статья не сильно разрослась, я намеренно старался создавать код проходов простым, пусть даже в ущерб их универсальности и оптимальности.
Ссылки
Исходный код — ссылка на gitlab
Модели и сцена взяты из игры Lander Missions: planet depths
За основу примера была взята следующая реализация обводки — ссылка на youtube
Примеры реализации собственных RenderFeature от Unity — ссылка на github.
Серия уроков по созданию собственного ScriptableRenderPipeline. После прочтения становится ясна общая логика работы RP и шейдеров — ссылка на туториалы.