Pull to refresh

Режимы смешивания в Unity

Reading time9 min
Views48K
Многие наверняка слышали о режимах смешивания (blend modes), которые присутствуют в большинстве популярных программ для работы с изображениями и видео. Там это — важный инструмент создания контента, давно уже ставший неотъемлемой их частью.

А что же в играх?

Допустим, появилась необходимость использовать Color Dodge смешивание для системы частиц или UI-художник сделал красивую графику для игрового интерфейса, но некоторые его элементы используют какой-нибудь Soft Light. А может, вам понадобилось подвергнуть трёхмерный объект Divide-смешиванию, чтобы получить эффект прямиком из кинокартин Линча?



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

Алгоритмы смешивания


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



При обычном (Normal) режиме смешивания, цвет каждого пикселя нижнего слоя (a) полностью замещатся цветом пикселя слоя, который его «перекрывает» (b). Здесь всё тривиально: таким образом «смешивается» бо́льшая часть графических объектов в играх.



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

fixed4 Screen (fixed4 a, fixed4 b) 
{ 	
	fixed4 r = 1.0 - (1.0 - a) * (1.0 - b);
	r.a = b.a;
	return r;
}

Обратите внимание, что в альфа-компоненту результирующего цвета (r.a) мы передаём значение альфы верхнего слоя (b.a), чтобы сохранить возможность независимо контролировать уровень прозрачности материала.



Overlay алгоритм работает условно: для «темных» участков происходит перемножение цветов, а для «светлых» используется аналог режима Screen.

fixed4 Overlay (fixed4 a, fixed4 b) 
{
	fixed4 r = a < .5 ? 2.0 * a * b : 1.0 - 2.0 * (1.0 - a) * (1.0 - b);
	r.a = b.a;
	return r;
}



Режим смешивания Darken сравнивает величины каждого из трёх цветовых каналов для двух слоёв и оставляет тот, который «темнее».

fixed4 Darken (fixed4 a, fixed4 b)
{ 
	fixed4 r = min(a, b);
	r.a = b.a;
	return r;
} 

Большинство остальных режимов функционируют по похожим схемам. Если вам интересно, реализацию ещё 18-ти алгоритмов смешивания на Cg можно найти здесь: gist.github.com/Elringus/d21c8b0f87616ede9014

Итак, нашу задачу в общем виде можно сформулировать следующим образом: для каждого пикселя материала объекта (b) найти пиксель, который находится «под ним» (a) и, используя выбранный алгоритм, «смешать» их.

Реализация с помощью GrabPass


Получив все необходимые алгоритмы смешивания, может показаться, что дело за малым: нужно только получить a — цвет пикселей, которые расположены «под» нашим объектом. Однако, именно этот этап оказался наиболее проблематичным при практической реализации.

Дело в том, что получить доступ к содержимому кадрового буфера (frame buffer), в котором и находится тот самый «задний слой», во время выполнения фрагментного шейдера (fragment shader) невозможно в связи с логикой работы конвейера визуализации (rendering pipeline):



Финальное изображение (final image) формируется уже после исполнения фрагментного шейдера, соответственно, мы не можем напрямую получить его во время выполнения Cg программы. Значит, нужно искать обходные пути.

На самом деле, потребность в данных о финальном изображении в рамках фрагментного шейдера возникает довольно часто. Реализация большинства пост-эффектов (post processing effects), например, немыслима без доступа к «финальной картинке». Для подобных случаев существует так называемый рендер в текстуру (render to texture): данные из кадрового буфера копируются в специальную текстуру, из которой затем выполняется чтение при следующем выполнении фрагментного шейдера:



В Unity существует несколько способов работы с рендер текстурой. В нашем случае наиболее подходящим будет использование GabPass — специального типа «прохода» (pass), который захватывает содержимое экрана в текстуру там, где объект будет нарисован. Как раз то, что нам нужно!

Создадим простой шейдер для UI-графики, добавим в него GrabPass и вернём из фрагментной функции результат смешения цветов по алгоритму Darken:

GrabDarken.shader
Shader "Custom/GrabDarken"
{
	Properties
	{
		_MainTex ("Sprite Texture", 2D) = "white" {}
		_Color ("Tint", Color) = (1,1,1,1)
	}

	SubShader
	{
		Tags
		{ 
			"Queue" = "Transparent" 
			"RenderType" = "Transparent" 	
		}
		
		Blend SrcAlpha OneMinusSrcAlpha

		GrabPass { }

		Pass
		{
			CGPROGRAM
			
			#include "UnityCG.cginc"
			
			#pragma vertex ComputeVertex
			#pragma fragment ComputeFragment
			
			sampler2D _MainTex;
			sampler2D _GrabTexture;
			fixed4 _Color;
			
			struct VertexInput
			{
				float4 vertex : POSITION;
				float4 color : COLOR;
				float2 texcoord : TEXCOORD0;
			};

			struct VertexOutput
			{
				float4 vertex : SV_POSITION;
				fixed4 color : COLOR;
				half2 texcoord : TEXCOORD0;
				float4 screenPos : TEXCOORD1;
			};
			
			VertexOutput ComputeVertex (VertexInput vertexInput)
			{
				VertexOutput vertexOutput;
				
				vertexOutput.vertex = mul(UNITY_MATRIX_MVP, vertexInput.vertex);
				vertexOutput.screenPos = vertexOutput.vertex;	
				vertexOutput.texcoord = vertexInput.texcoord;
				vertexOutput.color = vertexInput.color * _Color;
							
				return vertexOutput;
			}
			
			fixed4 Darken (fixed4 a, fixed4 b)
			{ 
				fixed4 r = min(a, b);
				r.a = b.a;
				return r;
			} 
			
			fixed4 ComputeFragment (VertexOutput vertexOutput) : SV_Target
			{
				half4 color = tex2D(_MainTex, vertexOutput.texcoord) * vertexOutput.color;
				
				// Находим координаты пикселя на рендер текстуре, 
				// который находится "под" текущим пикселем объекта
				float2 grabTexcoord = vertexOutput.screenPos.xy / vertexOutput.screenPos.w; 
				grabTexcoord.x = (grabTexcoord.x + 1.0) * .5;
				grabTexcoord.y = (grabTexcoord.y + 1.0) * .5; 
				// В зависимости от платформы,
				// ось V текстурной системы координат может быть перевёрнута.
				#if UNITY_UV_STARTS_AT_TOP
				grabTexcoord.y = 1.0 - grabTexcoord.y;
				#endif
				
				fixed4 grabColor = tex2D(_GrabTexture, grabTexcoord); 
				
				return Darken(grabColor, color);
			}
			
			ENDCG
		}
	}

	Fallback "UI/Default"
}


Для оценки результата, возьмём те же текстуры, что мы использовали в графическом редакторе во время демонстрации режимов смешивания:



Как видно на иллюстрации, результаты рендера UI-графики на Unity и документа в Photoshop — идентичны.

На этом можно было бы остановиться, если бы не одно «но»: рендер в текстуру — довольно трудоёмкая операция. Даже на ПК средней производительности, использование более 100 таких операций одновременно приводит к ощутимому снижению частоты кадров. Усугубляет ситуацию тот факт, что скорость работы GrabPass находится в обратной зависимости от разрешения дисплея. Представьте, какова будет производительность в случае выполнения подобной процедуры на каком-нибудь iPad с ультравысоким разрешением дисплея? В моём случае даже пара UI-объектов с «нетрадиционным» смешиванием в пустой сцене приводила к падению FPS ниже 20.

Реализация с помощью Unified Grab


Одна оптимизация напрашивается сама собой: почему бы не использовать единый GrabPass? Исходное изображение в рамках кадра остаётся неизменным, значит можно «снять» его один раз и затем использовать для всех последующих операций смешивания.

Unity предоставляет нам удобный способ реализовать задуманное. Достаточно передать в конструкцию GrabPass строку с именем переменной, в которой мы хотим хранить «общую» рендер текстуру:

GrabPass { "_SharedGrabTexture" }

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

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

Проблема становится очевидна, если «наложить» друг на друга два объекта, которые используют смешивание:



Кроме того, даже один GrabPass может слишком «дорого» обходиться для большинства мобильных устройств, а значит — нужно искать альтернативные подходы.

Реализация с помощью BlendOp


Раз использовать GrabPass в любом виде выходит слишком затратно, попробуем обойтись без него. Один из вариантов: попытаться изменить режим смешивания, который выполняется уже после фрагментного шейдера (в рамках конвейера визуализации Unity):



Данный этап используется в основном для обработки полупрозрачных объектов и возможности его модификации сильно ограничены — инструкции на Cg туда не вставишь. Можно лишь с помощью набора ключевых фраз конфигурировать, каким образом цвет, полученный из фрагментного шейдера, должен (и должен ли вообще) взаимодействовать с цветом, который находится «позади» него.

Операция определяется следующей конструкцией:

Blend SrcFactor DstFactor 

Логика такова, что исходный цвет (полученный из фрагментного шейдера) умножается на значение, которое возвращает первый операнд (SrcFactor), целевой цвет (цвет «заднего» слоя) умножается на второй операнд (DstFactor) и полученные значения складываются. Список операндов в свою очередь довольно ограничен: можно оперировать единицами, нулями, исходным и целевым цветами, а также результатами их инверсии.

Несколько расширяет возможности опциальная команда BlendOp, которая позволяет заменить сложение результата двух операндов вычитанием, взятием минимума или максимума.

Проявив немного фантазии, я смог реализовать следующие алгоритмы смешивания:

  • Darken:
    	BlendOp Min
    	Blend One One
    

  • Lighten:
    	BlendOp Max
    	Blend One One
    

  • Linear Burn:
    	BlendOp RevSub
    	Blend One One
    

  • Linear Dodge:
    	Blend One One
    

  • Multiply:
    	Blend DstColor OneMinusSrcAlpha
    


Модифицируем наш шейдер смешивания UI-графики в режиме Darken для использования BlendOp:

BlendOpDarken.shader
Shader "Custom/BlendOpDarken"
{
	Properties
	{
		_MainTex ("Sprite Texture", 2D) = "white" {}
		_Color ("Tint", Color) = (1,1,1,1)
	}

	SubShader
	{
		Tags
		{ 
			"Queue" = "Transparent" 
			"RenderType" = "Transparent" 	
		}
		
		BlendOp Min
		Blend One One

		Pass
		{
			CGPROGRAM
			
			#include "UnityCG.cginc"
			
			#pragma vertex ComputeVertex
			#pragma fragment ComputeFragment
			
			sampler2D _MainTex;
			fixed4 _Color;
			
			struct VertexInput
			{
				float4 vertex : POSITION;
				float4 color : COLOR;
				float2 texcoord : TEXCOORD0;
			};

			struct VertexOutput
			{
				float4 vertex : SV_POSITION;
				fixed4 color : COLOR;
				half2 texcoord : TEXCOORD0;
			};
			
			VertexOutput ComputeVertex (VertexInput vertexInput)
			{
				VertexOutput vertexOutput;
				
				vertexOutput.vertex = mul(UNITY_MATRIX_MVP, vertexInput.vertex);
				vertexOutput.texcoord = vertexInput.texcoord;
				vertexOutput.color = vertexInput.color * _Color;
							
				return vertexOutput;
			}
			
			fixed4 ComputeFragment (VertexOutput vertexOutput) : SV_Target
			{
				return tex2D(_MainTex, vertexOutput.texcoord) * vertexOutput.color;
			}
			
			ENDCG
		}
	}

	Fallback "UI/Default"
}


Для демонстрации воспользуемся всё теми же текстурами:



Проблема очевидна: из-за того, что мы используем этап смешивания «под свои нужды», альфа-смешивание проводить негде и прозрачность объектов просто игнорируется. С другой стороны, непрозрачные объекты смешиваются корректно и без потерь в производительности. Так что, если необходимо использовать один из режимов, который возможно воссоздать с помощью конструкции Blend и объект не имеет прозрачных областей — это, пожалуй, лучший вариант.

Реализация с помощью Framebuffer Fetch


Ранее я упоминал, что получить доступ к кадровому буферу из фрагментного шейдера невозможно. На самом деле, это не совсем так.

В 2013 году в спецификацию OpenGL ES 2.0 была добавлена функция EXT_shader_framebuffer_fetch, которая позволяет получить доступ к данным кадрового буфера из фрагментного шейдера. А несколько месяцев назад в релизе Unity 4.6.3 была анонсирована поддержка этой функции из Cg.

Модифицируем наш шейдер для использования Framebuffer Fetch:

FrameBufferFetchDarken.shader
Shader "Custom/FrameBufferFetchDarken"
{
	Properties
	{
		_MainTex ("Sprite Texture", 2D) = "white" {}
		_Color ("Tint", Color) = (1,1,1,1)
	}

	SubShader
	{
		Tags
		{ 
			"Queue" = "Transparent" 
			"RenderType" = "Transparent" 	
		}
		
		Blend SrcAlpha OneMinusSrcAlpha

		Pass
		{
			CGPROGRAM
			
			#include "UnityCG.cginc"
			
			#pragma vertex ComputeVertex
			#pragma fragment ComputeFragment
			
			sampler2D _MainTex;
			fixed4 _Color;
			
			struct VertexInput
			{
				float4 vertex : POSITION;
				float4 color : COLOR;
				float2 texcoord : TEXCOORD0;
			};

			struct VertexOutput
			{
				float4 vertex : SV_POSITION;
				fixed4 color : COLOR;
				half2 texcoord : TEXCOORD0;
			};
			
			VertexOutput ComputeVertex (VertexInput vertexInput)
			{
				VertexOutput vertexOutput;
				
				vertexOutput.vertex = mul(UNITY_MATRIX_MVP, vertexInput.vertex);
				vertexOutput.texcoord = vertexInput.texcoord;
				vertexOutput.color = vertexInput.color * _Color;
							
				return vertexOutput;
			}
			
			fixed4 Darken (fixed4 a, fixed4 b)
			{ 
				fixed4 r = min(a, b);
				r.a = b.a;
				return r;
			} 
			
			fixed4 ComputeFragment (VertexOutput vertexOutput
				#ifdef UNITY_FRAMEBUFFER_FETCH_AVAILABLE
				, inout fixed4 fetchColor : COLOR0
				#endif
				) : SV_Target
			{
				half4 color = tex2D(_MainTex, vertexOutput.texcoord) * vertexOutput.color;
				
				#ifdef UNITY_FRAMEBUFFER_FETCH_AVAILABLE
				fixed4 grabColor = fetchColor;
				#else
				fixed4 grabColor = fixed4(1, 1, 1, 1);
				#endif
				
				return Darken(grabColor, color);
			}
			
			ENDCG
		}
	}

	Fallback "UI/Default"
}





Идеально. Казалось бы, что ещё нужно? Никаких лишних операций, максимальная производительность, можно реализовать любую логику смешивания… Только вот иллюстрация выше — это фрагмент скриншота, снятого с iPad Air. А вот, например, в редакторе Unity наш шейдер работать просто откажется.

Проблема в том, что поддержка спецификации OpenGL ES полностью реализована лишь в устройствах под управлением iOS. На других же платформах (даже, если их графическая подсистема использует API OpenGL ES) эта функция может и не работать, поэтому рассчитывать на кроссплатформенность не приходится.

Заключение


Мы рассмотрели четыре реализации режимов смешивания на игровом движке Unity:

  • GrabPass наиболее ресурсоемок, но максимально корректно воспроизводит все режимы смешивания;
  • Unified Grab является оптимизацией GrabPass, серьёзно выигрывает в производительности при одновременном исполнении нескольких операций смешивания, но исключает возможность смешивать объекты друг с другом;
  • BlendOp работает максимально быстро, однако позволяет реализовать лишь ограниченное количество режимов и не поддерживает полупрозрачные материалы;
  • Frame Buffer Fetch работает так же быстро, корректно воспроизводит все режимы, но его использование возможно лишь на устройствах под управлением iOS.

Единственного универсального и кроссплатформенного решения найти не удалось, однако, комбинация представленных вариантов позволит воспользоваться «смешиванием» в большинстве случаев.

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



Также, рискну (AppEngine — ты выстоишь, я знаю!) опубликовать ссылку на WebGL сборку, где можно интерактивно поэкспериментировать с разными режимами смешивания.

Спасибо за внимание!
Tags:
Hubs:
Total votes 33: ↑30 and ↓3+27
Comments11

Articles