В последнее время я начал встречать вопросы об изменении/замене цветов текстур в шейдере. Существует несколько способов сделать это, выбор их зависит от конкретных условий применения.

В статье приведены примеры в Shader Graph, однако есть и ссылки на документацию Shader Graph (Node Library), где можно просмотреть сгенерированный код на HLSL.

Эти методики могут использоваться в следующих случаях:

  • Цвета игроков/команд, например, в RTS или в многопользовательских командных играх, или для изменения внешнего вида игрока.
  • Вариации цветов на пропсах окружения, растительности/листве, вариантах врагов и так далее.
  • Настраиваемые цвета и линейные градиенты, применяемые для VFX/эффектов частиц.
  • Заменяемые цвета палитры для пиксель-арта (спрайтов/карт тайлов). Могут использоваться для эстетики или связаны с геймплеем.
  • Эффекты постобработки, но это уже выходит за рамки моего поста.

Обычно в таких методиках в качестве входных данных используются какие-то текстуры (однако некоторые могут работать с шумами или процедурными формами, созданными из функций расстояний со знаком). Кроме того, неважно, будут ли использоваться текстуры для 3D-модели, 2D-спрайта, UI и так далее.

Некоторые плюсы и минусы:

  • Основное преимущество заключается в снижении объёма памяти текстур по сравнению с ручным созданием вариаций цвета и хранением их в отдельных текстурах (или в атласе/массиве текстур).
  • Проще настройка свойств/полей цветов материалов/скриптов, чем при редактировании файлов текстур.
  • Методики, изменяющие UV-координаты в ноде Sample Texture 2D на фрагментном этапе (см. разделы про градиент и текстуру палитры), могут быть чуть более затратными (это заметнее на мобильных платформах), потому что это создаёт так называемые dependent texture read. Насколько я понимаю, это приводит к тому, что GPU не может выполнять предварительную загрузку текстур, что вызывает повышение задержек при считывании/сэмплировании текстур. Но всегда хорошо попробовать несколько методик и сравнить/профилировать их!


Tint


Один из простейших способов изменения цвета — это tint, для которого обычно требуется нод Multiply между входными данными и нужным цветом (нод или свойство Color). В качестве входных данных здесь обычно используется текстура в градациях серого (значения float в интервале от 0 до 1). В идеале значения должны быть близки к 1 (белому), чтобы можно было получить полный диапазон цветов, оттенок которого мы стремимся получить.


(Текстура травы из Kenney assets — Foliage Sprites)

Цвет здесь может использовать режим HDR, чтобы мы могли иметь контроль и над яркостью. (Однако это часто используется для эффектов сияющих частиц, а не для травы!)

Также здесь нод Color обычно преобразуется в свойство color, благодаря этому его можно редактировать в материале или задавать через код на C#. Но в этом посте мы оставим их в виде нодов, чтобы вы чётко видели, какой цвет был назначен.

(Если вы незнакомы со свойствами, то прочитайте мой пост Intro to Shader Graph — Properties)

Lerp


В показанном выше примере выполняется умножение, поэтому значение 0 (чёрный цвет) на входной текстуре всегда будет оставаться чёрным, однако мы можем получить контроль и над этим цветом.

Если предположить, что наши входные данные в градациях серого/float меняются в интервале от 0 до 1, можно инвертировать их при помощи нода One Minus и придать оттенок при помощи другого цвета (через Multiply), а затем выполнить сложение (Add) двух tint.

Это вычисление аналогично линейной интерполяции, поэтому проще использовать нод Lerp. Входные данные в градациях серого подаются на вход T, а A и B — это два нода/свойства colour.


(Также обратите внимание, что текстура травы отредактирована, чтобы в ней появился более тёмный градиент)

«Фон» показанного выше превью выглядит голубым, потому что RGB-данные изначально содержали чёрные пиксели, но нам не стоит волноваться, поскольку текстура содержит и альфа-канал (выход A, соединённый с портом Alpha в Master Stack). Благодаря этому такие пиксели на готовом результате становятся прозрачными, как в Main Preview, так и в сцене/игре. (При условии, что граф установлен в режим поверхностей Transparent или использует Opaque и Alpha Clipping.)

Примечание: нод Lerp также полезен для накладывания нескольких текстур/маскировки областей. В этом случае мы используем выходы RGBA из текстур, а не нод/свойства Color. Дополнительную информацию и примеры см. в моём FAQ — Layering textures/colours.

Дополнительные примечания о цветовых пространствах
Приведённые выше примеры выполняют интерполяцию по sRGB/линейному цветовому пространству (в зависимости от цветового пространства, заданного в параметрах проекта. См. страницу документации: Linear or gamma workflow). Однако есть и другие цветовые пространства, по которым мы можем выполнять интерполяцию для получения других результатов. На самом деле, нет «правильного» пространства, всё зависит от нужного вам результата. Однако эти преобразования между разными цветовыми пространствами делают шейдер чуть более затратным.

Например, можно выполнять интерполяцию в пространстве HSV. В графе шейдера это выполняется при помощи нода Colorspace Conversion. Для примера посмотрите на следующие результаты:


В этом проекте используется линейное цветовое пространство. Lerp между синим и жёлтым создаёт серый переход, а lerp через Hue (вход X значения Vector3, поскольку мы выполняем преобразование из пространства HSV) приводит к более цветному спектру. Также мы можем выполнять интерполяцию других компонентов, но в этом случае Saturation остаётся равной 0.8, а Brightness равной 1.

Код на HLSL, который используется для каждого преобразования, можно посмотреть в Node Library — Colorspace Conversion, а функции — в render-pipelines.core/ShaderLibrary/Color.hlsl.

Для более подробного изучения этой темы можно прочитать статью Алана Цукони The Secrets of Colour Interpolation

Каналы цвета Tint


Также можно расширить методику с tint для получения до 3-4 цветов, упаковав несколько значений входных данных в градациях серого/float в каждом канале цвета текстуры (красном, зелёном, синем и альфе).

В некоторых случаях альфа-канал может требоваться для прозрачности, поэтому его нельзя использовать (достаточно часто такое бывает у спрайтов). Но для других каналов мы можем применить этот способ (при помощи Multiply с нодом или свойством Color), а затем скомбинировать их при помощи нодов Add (или Maximum).


Спрайт космического корабля отредактирован из Kenney Assets — Space Shooter Redux

Если альфа-канал не требуется для прозрачности (часто в случае 3D-моделей), то его тоже можно использовать для добавления оттенка. Мне кажется, проще инвертировать этот канал, потому что можно тогда можно использовать стирательную резинку для «рисования» областей с нулевой альфой и продолжать видеть цветные области. Кроме того, программы обычно не хранят данные RGB в областях с нулевой альфой, и хотя есть возможности переопределить такое поведение, инвертировать канал проще.

Чтобы учесть это в шейдере, мы используем перед нодом Multiply нод One Minus.


Ещё один пример с tint, в котором текстура наложена на 3D-модель и содержит тёмные оттенки для имитации затенения. Альфа-канал используется для маскировки ног/обуви.

Текстура маски


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

Распространённый (но довольно наивный) способ для этого заключается в хранении значения 1 (или 255) в канале текстуры маски (для той области, в которой нужна раскраска), и значения 0 во всех остальных местах. Это хорошо работает с режимом фильтрации Point текстуры, однако при использовании Bilinear (или Trilinear) может вызывать белые артефакты/швы, растекающиеся вдоль края. Для текстур на 3D-моделях это может и не быть проблемой, если UV-острова остаются внутри маски (например).


(Отредактированный спрайт мужчины-приключенца из Kenney assets — Toon Characters 1)

Обратите внимание на белые края вокруг рубашки на превью.

Подробности того, почему это происходит, можно прочитать в статье Бена Голуса: The Team Color Problem. В качестве альтернативы в ней предлагается использование «предварительного умножения» входных данных и маски; иными словами, нужно отделить область, которая должна быть раскрашена, во вторую текстуру, оставив чёрные пиксели на обычной:


Взяв эти данные в качестве входных, мы можем придать оттенок текстуре маски и сложить их друг с другом при помощи Add.


Улучшенный способ маскирования оттенка — белых краёв нет!

Здесь маска использует только красный канал, поэтому для маскирования других областей можно использовать остальные каналы. Или если требуется только красный канал, для экономии памяти текстуру можно импортировать как тип «Single Channel» (выбрать «Red»). Однако вам также может понадобиться Colorspace Conversion (из RGB в Linear) на выходе текстуры, если мы находимся в линейном цветовом пространстве (а не Gamma).

Дополнительные примечания о спрайтах/текстурах с прозрачностью
При импорте спрайтов (или Default-текстур, где «Alpha Is Transparency») в превью Shader Graph цвета в полностью прозрачных областях могут растягиваться. Но не волнуйтесь, это сделано намеренно, как способ устранения артефактов, как объяснено в FAQ.

Если вы задаётесь вопросом, почему этого не происходило в моих примерах: я импортировал основную текстуру без альфа-канала, потому что хотел, чтобы пример был понятнее. Но даже с альфа-каналом и этим растяжением цветов готовый результат выглядел бы хорошо, при условии, что выход A из Sample Texture 2D соединён с портом Alpha в Master Stack.

Однако текстура маски всё равно должна оставаться с чёрным фоном, чтобы избежать растягивания цветов. Хотя если бы у неё был альфа-канал, мы могли бы вместо этого выполнить Multiply выходов R и A перед применением tint.

Нод Replace Color


Давайте рассмотрим нод Replace Color, который содержится в Shader Graph (так же Color Mask, которая, по сути, является тем же, только без указания цвета для линейной интерполяциии).

HLSL
float3 ReplaceColor(float3 In, float3 From, float3 To, float Range, float Fuzziness){
    float Distance = distance(From, In);
    return lerp(To, In, saturate((Distance - Range) / max(Fuzziness, 1e-5)));
}

float3 ColorMask(float3 In, float3 MaskColor, float Range, float Fuzziness){
    float Distance = distance(MaskColor, In);
    return saturate((Distance - Range) / max(Fuzziness, 1e-5));
}

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


Нод сработал с пиксель-артом космического корабля (созданным мной), но оставил некоторые края/артефакты на другом спрайте (из Kenney Assets — Space Shooter Redux). Возможно, с одним из альтернативных цветов космических кораблей или после редактирования спрайта нод заработал бы лучше, однако смысл в том, что приведённые выше примеры с Tint дают более качественные результаты и при этом менее затратны!

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

Я видел несколько туториалов с использованием этого нода, но на самом деле он не так малозатратен, потому что вычисляет расстояние между входами In и From (в расчёте используется квадратный корень). Для нескольких цветов это может быть не такой уж проблемой, особенно при разработке для мощных платформ. Однако для мобильных платформ лучше подойдут другие способы из этой статьи.

Нод Blend


Также можно использовать нод Blend для различных способов смешения цветов, чем-то схожего с комбинированием слоёв текстур в графических редакторах наподобие Photoshop, GIMP и т. п. Внутри всё это использует Lerp, однако включает в себя дополнительные вычисления входа B перед lerp.

В частности, режим Overwrite — полный эквивалент Out = lerp(Base, Blend, Opacity);

Вот несколько примеров:


Ноды Blend, использующие режимы Overlay, Linear Light и Screen

В этом примере текстура накладывается на слой Base, а цвет используется в качестве слоя Blend. Opacity присвоено значение 1 для полной силы смешения (однако оно может быть и больше 1, так как интерполяция не ограничена). При Opacity, равном 0 результат всегда будет таким же, как и входные данные Base.

Если вам интересен код на HLSL с использованием разных режимов смешения, см. Node Library — Blend.

Gradient Ramp


Можно также использовать градиент (иногда называемый ramp, особенно когда применяются сплошные полосы цветов, а не интерполированные ключи цветов), чтобы повысить уровень контроля над цветами, получаемыми из входных данных в градациях серого (float 0-1).

Это можно рассматривать как последовательность remap и lerp, скомбинированных вместе; именно так внутри устроены объект Gradient и нод Sample Gradient в Shader Graph. Пример кода на HLSL можно увидеть в Node Library — Sample Gradient.


Однако Shader Graph не поддерживает передачу этих объектов градиента материалу или C#, из-за чего они гораздо менее полезны. Я перечислил несколько альтернатив в своём посте Intro to Shader Graph — Gradient, но рекомендуемый мной хранит градиент в виде Texture2D и использует нод Sample Texture 2D, который также виден на показанном выше изображении. Стоит учесть, что текстуре нужно включить Clamp wrap mode.

Примеры, в которых можно использовать эту методику:


Текстура/массив палитры


Концепция такого Gradient/Ramp также подойдёт для пиксель-арта, однако вместо интерполяции цветов можно реализовать соседние значения, указывающие на совершенно разные цвета. В этом случае входная текстура использует конкретные цвета, соответствующие координате X текстуры (или индексу массива), содержащей палитру.

Если входная текстура имеет формат RGBA32, то мы будем иметь дело с 8-битными каналами, которые могут хранить значения в интервале 0-255 (хотя в шейдере они всё равно выглядят как 0-1). Это значит, что мы можем поддерживать 256 уникальных индексов, поэтому палитра (текстура или массив) может содержать до 256 цветов. (При использовании только красного канала. Если вы будете использовать для 2D-палитры красный и зелёный канал, то она сможет поддерживать до 65536 цветов, хотя это кажется излишним.)

(Здесь также предполагается, что в параметрах входной текстуры используется режим фильтрации Point и Compression: None)

Вот пример всего с пятью цветами:


Обратите внимание, что для входной текстуры отключен sRGB. Также текстура палитры использует Clamp wrap mode.

Здесь размер текстуры палитры составляет 5x1 (она содержит чёрный, светло-серый, голубой, жёлтый и белый цвета).

Значения, хранящиеся во входной текстуре, кратны 255/(5-1) (то есть 0, 63,8, 127,5, 191,3 и 255). Однако входная текстура всё равно содержит альфа-канал, поэтому можно считать «полностью прозрачный» ещё одним цветом палитры (то есть 6x1 и значения, кратные 255/(6-1)), и тогда входные данные можно импортировать как одноканальную текстуру для потенциальной экономии места в памяти.

Проблема такой методики заключается в том, что не так уж легко вручную создавать текстуры, хранящие эти индексы (особенно при больших палитрах, поскольку оттенки будут очень близко друг к другу). Однако для их запекания/генерации можно использовать скрипт редактора C#. У меня нет готового скрипта, но думаю, что он должен выглядеть примерно так:

  • Для каждого пикселя в цветной палитре пиксель-арта
  • обходим в цикле текстуру/массив палитры для нахождения соответствующего цвета с его индексом,
  • присвоить пикселю в сгенерированной текстуре значение new Color(index / (paletteSize-1), 0, 0, inputPixel.a)


Дополнительные примечания на случай хранения палитры в виде массива
Если вы храните палитру в виде массива:

  • Вам придётся преобразовывать значение градации серого в интервале 0-1 в индекс.
    • например, (int)(In * numberOfColours - 1)

  • Я слышал, что доступ к массиву может быть немного быстрее, чем поиск в текстуре (хотя как всегда вам стоит самим это проверить и профилировать на целевой платформе, чтобы убедиться, что разница есть)
  • Материалы не поддерживают сериализацию для массивов, поэтому они недоступны материалу, однако их можно задавать через C#. Существует Material.SetColorArray, однако в скриптовых конвейерах рендеринга (например, в URP/HDRP) нужно использовать set array глобально (при помощи Shader.SetGlobalVectorArray), чтобы избежать проблем, связанных с SRP Batcher.
    • Разумеется, это означает, что всё будет использовать один массив палитры, но если вы хотите менять палитры для каждого объекта, это должно быть возможным при помощи передачи смещения (через свойство Float), которое можно использовать для сдвига индекса (Add). В таком случае массив на самом деле будет содержать несколько палитр.
    • Это не задокументировано, но я полагаю, что массивы ограничены максимальной длиной 1023, поэтому это ограничит возможное количество палитр. Если это станет проблемой, то лучше использовать текстуру.

  • Если в проекте используется линейное цветовое пространство, то у вас могут возникнуть проблемы из-за разницы между цветовыми пространствами gamma/linear, потому что упомянутые выше функции не работают с этим. Проблему можно решить, использовав .linear для объекта Color, прежде чем помещать его в массив. При глобальном присвоении вам также понадобится вручную преобразовывать все цвета для заполнения массива Vector4[], потому что функции SetGlobalColorArray не существует.

Комментарии (0)