Визуальные эффекты для игры жанра Tower Defence. Подборка шейдеров

Визуальные эффекты для игры жанра Tower Defence. Подборка шейдеров

В последнее время я не был занят, поэтому открыл для себя новое увлечение: писал визуальные эффекты и спецэффекты для игры Hacker F-301 в жанре Tower Defense. В этом деле у меня есть некоторый опыт, поэтому я планирую написать и поделиться им. Надеюсь, что вы сможете дать ценные комментарии.

В этой статье будут представлены:

  • Inking (Обводка модели)
  • Hologram (Голограмма модели)
  • See-Through (Визуализация части объекта, которая перекрыта стеной)
  • Force Field (Силовое поле)
  • Video Glitch (Имитация влияния электронных помех на экране)

Inking (Обводка модели, контур)

Inking — это эффект, применяемый к skinned meshes, в котором те очерчиваются по контуру красивыми серыми или черными линиями. Его преимущество заключается в том, что модель может быть более отчетливо прорисована на фоне, особенно в областях с низким контрастом. Эффект имеет множество применений, например, как в League of Legends:

Без эффекта Inking
Применен эффект Inking

Два изображения выше взяты из блога, который анализирует процесс рендеринга LOL [1]. Первое изображение — это исходное, а второе — результат добавления спецэффекта Inking. Мы видим, что после добавления Inking все модели легче отличить от фона, т.е. обводка контура играет роль выделения. В данном примере также используются более толстые линии обводки, что придает изображению стилистику комикса.

Метод реализации (вкратце)

Существует много способов реализации Inking, но можно условно разделить их все на две категории: точечные и поверхностные. Какой тип использовать, зависит от конкретной ситуации:

  • Fresnel (Френель). Очень похож на Rim Lighting, использует скалярное произведение направления линии прямой видимости и нормали к данной точке для определения и выделения края.

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

Освещение краев изображения

Недостатки: Толщина линии не может контролироваться. Поскольку метод Френеля использует нормаль к модели и линию обзора камеры, он связан только с нормальным направлением к каждой поверхности, а не с информацией о ее глубине.

  • Mesh Doubling (Удвоение сетки) — очень похож на мультяшный Toon эффект (сэл шейдинг). Для его достижения необходим отдельный проход. Нужно перерисовать модель, расширяя все поверхности в нормальном направлении, а затем вырежьте выступающие края. Это также решение, которое я использовал.

Преимущества: Высокая эффективность, хорошая адаптивность, можно контролировать толщину линий.

Недостатки: Линии не являются непрерывными. Применять метод на гладких поверхностях получается очень хорошо, но на острых краях часто возникают разломы, и можно нарисовать только самый внешний контур без какой-либо обработки внутренней структуры.

Таким был бы результат простого Mesh Doubling
  • Edge Detection (Определение краев) — собственный эффект постобработки в Unity [2]. Основной принцип алгоритма с использованием Sobel Filter [3] — обнаружение разницы в глубине нескольких смежных пикселей. Используется 3×3 блок для свертки исходного изображения, который отфильтровывает большую часть информации о разнице в глубине. Этот метод был использован в LOL.

Преимущества: Метод может использоваться и как специальный постэффект, и применительно к модели. Точный, толщина линии контролируема.

Недостатки: Намного дороже, чем две вышеупомянутые схемы. Но его нагрузка постоянна и не имеет отношения к обработанному изображению.

  • С помощью геометрического шейдера проверить соседние многоугольники, чтобы определить смежные ребра и включенные углы, а затем построить геометрию контура отдельно.

Преимущества: Самый точный метод на сегодняшний день, легко контролировать толщину линии.

Недостатки: Очень дорогой. Обычно используется только для автономного рендеринга.

Конкретная стратегия реализации

Я использовал метод удвоения сетки. Проблема, которая должна быть решена здесь, это прерывистость линий. Идея заключается не в том, чтобы расширить поверхность строго в нормальном направлении, а в том, чтобы провести линию между положением стандартизированного точечного элемента и нормальным направлением. Тогда при расширении поверхность также будет сходиться к точечным элементам, что минимизирует разрыв новой сетки.

Формулы для получения контура методом удвоения сетки

Здесь L представляет вектор смещения, W — толщину линии контура, D — расстояние между объектом и камерой, V — нормализованные координаты вершины, указывающие направление, N — вектор вершины, f — параметр интерполяции.

На любых острых углах получается разрыв линии

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

Если добавить интерполяцию, проблема решается

Вектор расширения куба на приведенной выше диаграмме интерполирован с параметром 0.032, и ощущение разрыва больше не возникает.

Вот основной код эффекта Inking (очень короткий), а ниже некоторые замечания по реализации:

vertexOutput vert ( appdata_base v )
{
		vertexOutput o;
          
		o.pos = mul ( UNITY_MATRIX_MVP, v.vertex );
		float3 dir = normalize ( v.vertex.xyz );
		float3 dir2 = v.normal;
		
		dir = lerp ( dir, dir2, _Factor );
		dir = mul ( ( float3x3 ) UNITY_MATRIX_IT_MV, dir );
		float2 offset = TransformViewToProjection ( dir.xy );
		offset = normalize ( offset );
		float dist = distance ( mul ( UNITY_MATRIX_M, v.vertex ), _WorldSpaceCameraPos );
		o.pos.xy += offset * o.pos.z * _OutlineWidth / dist;
 
		return o;
}

Параметром интерполяции является _Factor. Вектор преобразования основан на транспонированной матрице представления модели. Для того, чтобы конечная толщина линии оставалась постоянной в мировых координатах и не изменялась при движении камеры, положение расширенного пикселя должно быть разделено на расстояние до камеры. Последней функции шейдера элемента нужно предложение return_Color. Наконец, вы должны быть убедиться, что отрезаете переднюю сторону, иначе вы получите не силуэт, а пленку, которая обернет модель.

Скриншот без эффекта обводки
Эффект применен и выделены турели на заднем плане

Два вышеприведенных скриншота сделаны до и после добавления эффекта. Мы видим, что после использования Inking турели выглядят более “четкими” на заднем плане.

В некоторой степени эффект Inking похож на SSAO. Эти визуальные эффекты пытаются добавить более глубокие тени на стыке геометрии, чтобы сделать изображение более контрастным. SSAO описано в моем посте «Подборка шейдеров №4».

Hologram (Голограмма модели)

Голограмма представляет собой трехмерное изображение, обычно от лазерного источника света и в виде интерференционных полос, на котором объект записан в виде трехмерного светового поля.

Концепт арт с голограммой
Голограмма в игре Mass Effect

Первая картинка выше — это концепт голографического проектора, а вторая — сцена из Mass Effect.

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

Визуальные эффекты голограммы в кино
Мы будем делать такую голограмму

Первая попытка (Наивная)

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

v2f vert ( appdata_base v )
{
    v2f o;
    o.pos = UnityObjectToClipPos ( v.vertex );
    o.uv = v.texcoord.xy;
    o.screenPos = ComputeScreenPos ( o.pos );
    o.dist = distance ( mul ( UNITY_MATRIX_M, float4 ( 0.0, 0.0, 0.0, 0.0 ) ), _WorldSpaceCameraPos );
    return o;
    }
fixed4 frag ( v2f i ) : COLOR
{
    fixed4 finalColor;
    float2 uvNormal = UnpackNormal ( tex2D ( _NormalTex, i.uv ) ) / i.dist;
    float2 screenUV = ( i.screenPos.xy / i.screenPos.w + float2 ( _TilingX * _Time.y, _TilingY * _Time.y ) ) * i.dist * _Distance;
    fixed3 color = _Color * tex2D ( _MainTex, screenUV + uvNormal ) * _Emission;
    fixed alpha = _Color.a * max ( min ( color.r, color.g ), color.b );
    return fixed4 ( color, alpha );
}

Полученные результаты, естественно, примитивны:

Примитивная голограмма

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

Вторая попытка (с учетом глубины и информации о нормалях)

Присмотревшись к предыдущим двум рендерингам, мы увидим, что чем больше dot(viewDirection, normalDirection) (в дальнейшем называемый скалярным произведением), тем темнее свет на голограмме. Чтобы сделать весь эффект более детализованным, я решил рассчитать скалярное произведение для областей с большим и малым его значением по отдельности. Я также обновил расчёт интерференционных полос: чтобы не допустить слишком светлого цвета всей голограммы, я задал ей основной цвет однородной интенсивности _Strength, а затем добавил значение для интерференционных полос.

Формулы для улучшенной голограммы

Полученные результаты следующие:

Красивая голограмма в игре

Код шейдера выглядит так:

v2f vert ( appdata_base v )
    {
        v2f o;
        o.pos = UnityObjectToClipPos ( v.vertex );
        o.projPos = ComputeScreenPos ( o.pos );
        o.uv = v.texcoord.xy;
        o.normalDir = UnityObjectToWorldNormal ( v.normal );
        o.posWorld = mul ( UNITY_MATRIX_M, v.vertex );
        return o;
    }
    fixed4 frag ( v2f i ) : COLOR
    {
        fixed alpha = 1;
        float sceneZ = LinearEyeDepth ( SAMPLE_DEPTH_TEXTURE_PROJ ( _CameraDepthTexture, UNITY_PROJ_COORD ( i.projPos ) ) );
        float partZ = i.projPos.z;
        float fade = saturate ( _InvFade * ( sceneZ - partZ ) );
        alpha *= fade;
                
        float3 viewDirection = normalize ( _WorldSpaceCameraPos.xyz - i.posWorld.xyz );                
                 
        float4 objectOrigin = mul ( unity_ObjectToWorld, float4 ( 0.0, 0.0, 0.0, 1.0 ) );
        float dist = distance ( _WorldSpaceCameraPos.xyz, objectOrigin.xyz );
        float2 wcoord = i.projPos.xy / i.projPos.w;
wcoord.x *= _Inter.y;
        wcoord.y *= _Inter.z;
        wcoord *= dist * _Inter.x;
        
        float3 nMask = _Strength;
                 
        float3 hMask = tex2D( _MainTex, wcoord + float2 ( 0, _Time.x * _Inter.w ) );
        float fresnel = pow ( abs ( dot ( viewDirection, i.normalDir ) ), _FresPow ) * _FresMult;
        float3 bLayer = lerp ( _bLayerColorA, _bLayerColorB, fresnel );
        float fresnelOut = pow ( 1 - abs ( dot ( viewDirection, i.normalDir ) ), _FresPowOut ) * _FresMultOut;
        float3 bLayerC = _bLayerColorC * fresnelOut;
        float3 final = saturate ( ( hMask + nMask ) * ( bLayer + bLayerC ) ) * alpha;
            
        return float4 ( final * _Fade, 1) ;
    }

See-Through (Прозрачность, визуальный эффект перспективы)

Я вижу сквозь стены!

В игре всегда есть несколько очень важных объектов, и нужно убедиться, что игрок может каким-то образом видеть их в любое время. Например, в серии Hitman игроки могут использовать этот эффект, чтобы узнать позицию врага и определить свою собственную тактику. В играх RTS (таких как Red Alert 3), юниты, которые замаскированы, также будут представлены другим цветом, чтобы игрок не заметил их присутствия.

Как сделать эффект

Игровой объект разделен на два слоя: Occluder (заслоняющий) и Behind (задний). Надо вывести на экран ту часть слоя Behind, которая закрыта слоем Occluder. Здесь используются две камеры для отображения информации о глубине двух слоев соответственно, чтобы получить две Render Target (далее по тексту RT). Выполните предварительный рендеринг всех деталей с глубиной Behind RT, превышающей соответствующую глубину Occluder RT, без выполнения какой-либо обработки остальных, и поместите результат в новый RT. Наконец, нарисуйте полноэкранный квад, и примените RT напрямую.

Для того, чтобы увеличить глубину результата рендеринга и отразить структуру заслоненного объекта, камера, визуализирующая слой Behind, может одновременно рендерить обычную информацию, а затем связать интенсивность цвета с обычным направлением при рендеринге конечного RT.

Ниже приведен результат рендеринга:

Как заставить камеру с помощью Rendering Path for Forward получать глубину и обычную информацию о сцене? Используйте отдельный шейдер, чтобы указать его поведение при рендеринге, а затем используйте Camera.RenderWithShader.

Шейдеры, которые определяют способ рендеринга камеры:

struct v2f 
{
	float4 pos : POSITION;
	float4 nz : TEXCOORD0;
};
            
v2f vert( appdata_base v )
{
	v2f o;
	o.pos = mul( UNITY_MATRIX_MVP, v.vertex );
	o.nz.xyz = COMPUTE_VIEW_NORMAL;
	o.nz.w = COMPUTE_DEPTH_01;
	return o;
}
            
fixed4 frag( v2f i ) : COLOR 
{
	return EncodeDepthNormal ( i.nz.w, i.nz.xyz );
}

Шейдер, который обрабатывает два RT:

v2f vert ( appdata_img v )
{
	v2f o;
	o.pos = UnityObjectToClipPos ( v.vertex );
	o.uv = v.texcoord.xy;
	return o;
}
fixed4 frag ( v2f i ) : COLOR
{
	float behindDepth, occluderDepth;
	float3 behindNormal, occluderNormal;
	DecodeDepthNormal ( tex2D ( _Behind, i.uv ), behindDepth, behindNormal );
	DecodeDepthNormal ( tex2D ( _Occluder, i.uv ), occluderDepth, occluderNormal );
	fixed4 scene = tex2D ( _MainTex, i.uv );
	fixed4 pattern = tex2D ( _PatternTex, ( i.uv + _SinTime.w / 100 ) / _PatternScale );
	if (behindDepth > 0 && occluderDepth > 0 && behindDepth > occluderDepth)
	{
		float factor = 0.1 + 0.9 * pow ( max ( dot ( float3 ( 0, 0, 1 ), behindNormal ), 0.0 ), 1.2 );
		return fixed4 ( lerp ( scene, _Color, lerp ( factor, factor * pattern.r, _PatternWeight ) ) );
	}
 	else
		return scene;
}

Небольшая проблема

Эта реализация дороговата: для каждой группы слоёв Occluder и Behind надо использовать две камеры для визуализации по отдельности (хотя нужны только глубина и нормаль, а не расчет освещения), а затем выполнить постэффект в полноэкранном режиме. Например, в Red Alert 3, есть в общей сложности шесть лагерей юнитов, и юниты каждого лагеря должны быть замаскированы и рендериться в другом цвете. С моим методом надо использовать 7 (6 * Behind + 1 * Occluder) камер плюс 6 слоев постэффектов, что явно невозможно в исполнении.

По моему предположению, в Red Alert 3 должен быть дополнительный материал для каждого юнита, который доступен только в случае ZTest Fail. Не знаю, верно ли это, просто хотел поделиться с вами.

Force Field (Силовое поле)

Силовое поле в Titan Fall 2

Выше приведен скриншот из TitanFall 2. Титан окружен полусферическим щитом силового поля. Если пуля попадет в экран, в ответ на попадание изменится текстура и произойдет искажение фона.

Обратите внимание, что этот ForceField имеет эффект подсветки, которпая активируется при пересечении с другими объектами. Про Highlight Intersection я писал во втором выпуске своих постов про визуальные эффекты.

Как реализовать ForceField

Сам щит не представляет сложности для реализации. Нужен только полупрозрачный материал. Самое сложное это смещение при ударе.

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

Формула для подсчета смещения в текстуре силового щита

Чтобы поддерживать несколько точек столкновения, в шейдере можно использовать n 4 * 4 матриц для представления точек 4 * n столкновения. Достаточно, чтобы каждый фрагмент проходил всю информацию о точках столкновения один раз. После удара по щиту эффект динамической текстуры будет постепенно уменьшаться со временем, поэтому альфа динамической текстуры также должна со временем ослабевать (см. рисунок ниже).

Демонстрация эффекта

Время может контролироваться скриптом или шейдером. Просто убедитесь, что время не отрицательное.

Вот код шейдера:

v2f vert ( appdata_base v )
    {
        v2f o;
        v.vertex += float4 ( v.normal * _MeshOffset, 0.0 );
        o.pos = UnityObjectToClipPos ( v.vertex );
        o.uv = v.texcoord.xy;
        float3 worldPosition = mul ( UNITY_MATRIX_M, v.vertex );
        float3 viewDirection = normalize ( worldPosition - _WorldSpaceCameraPos );
        o.factor =  ( dot ( UnityObjectToWorldNormal ( v.normal ), viewDirection ) );
        for ( int ii = 0; ii < 4; ii++ )
        {
            o.dist[ ii ] = distance ( _CollisionPoints[ ii ].xyz, v.vertex.xyz );
        }
        return o;
    }
    fixed4 frag ( v2f i ) : COLOR
    {
        fixed4 finalColor;
        float2 uvNormal = UnpackNormal ( tex2D ( _NormalTex, i.uv * _NormalScale + float2 ( _TilingX * _Time.y, _TilingY * _Time.y ) ) );
        fixed3 color = tex2D ( _MainTex, ( i.uv ) * _MainScale + uvNormal ) * _Color * _Emission;
        float fallOff = saturate ( pow ( 1.0 - i.factor, _FallOff ) * pow ( i.factor, _FallOff2 ) );
        ///Магическое число! 
        half alpha = 0.01;
        alpha += saturate ( pow ( _CollisionTime.x, 0.5 ) - ( float ( i.dist[ 0 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.x ), 0.0 );
        alpha += saturate ( pow ( _CollisionTime.y, 0.5 ) - ( float ( i.dist[ 1 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.y ), 0.0 );
        alpha += saturate ( pow ( _CollisionTime.z, 0.5 ) - ( float ( i.dist[ 2 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.z ), 0.0 );
        alpha += saturate ( pow ( _CollisionTime.w, 0.5 ) - ( float ( i.dist[ 3 ] ) / _MaxDistance ) ) * _BrightnessCollision * max ( sign ( _CollisionTime.w ), 0.0 );
        finalColor.rgb = color;
        finalColor.a = alpha * pow ( finalColor.b, 2.0 );
        return finalColor;
    }

Следующий скрипт используется для вычисления пространственных координат точки столкновения и присвоения значений шейдерам:

public void ShieldOnWorldSpacePoint (Vector3 point)
{
	Vector3 localPosition = transform.InverseTransformPoint (point);
	Vector4 toShield = new Vector4 (localPosition.x, localPosition.y, localPosition.z, 1f);
	effectTime[currentIndex] = duration;
	collisionPoints.SetRow(currentIndex, toShield);
	material.SetMatrix("_CollisionPoints", collisionPoints);
	material.SetVector("_CollisionTime", effectTime);
	currentIndex++;
	currentIndex %= 4;
}

Я использую матрицу 4*4, так что могу представить 4 точки столкновения. Этого на самом деле вполне достаточно, потому что мы можем сократить продолжительность каждого столкновения и своевременно обновить его. Заметьте, что Doom Shield в TitanFall2 также должен использовать не более 8 точек столкновения. Я использовал Легион (тот, у которого есть тяжелый пулемет, который может выдавать дюжину пуль в секунду), а Титаны открыли Doom Shield с противоположной стороны. Похоже, было только 6 или 7 точек столкновения.

В моей текущей реализации нет HeatWave, т.е. щиты не искажают фон при попадании в них. Это потому, что в моей игре щит прикреплен к башне, и башня занимает меньше места на экране. Не нужно делать эффект очень сложным, так как башен может быть много, а производительность — это большая проблема. Поэтому используется метод прямого смешивания. Если вы хотите исказить фон, используйте GrabPass [4], затем добавьте слой фильтра Bump Map для uv [5].

Video Glitch (Интерференционный эффект, помехи)

В TitanFall 2 игра информирует игрока о том, что Титан подвергается мощной огненной атаке, создавая впечатление, что на экране пошли электронные помехи.

Искажение картинки, визуальные эффекты из Titan Fall 2

В Alienation основной интерфейс игры иногда испытывает прерывания, аналогичные перебоям в аналоговом сигнале ЖК-дисплея. (Здесь нет изображения, поскольку я не смог экспортировать скриншот на PS4).

В конечном итоге этот эффект помех можно разделить на две части:

  1. Искажение картинки.
  2. Цветовое смещение.

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

Ниже приведен код:

fixed4 frag ( v2f i ) : COLOR
    {
        float u = i.uv.x;
        float v = i.uv.y;
        float horizonNoise = tex2D ( _NoiseTex, float2 ( v, _Time.x ) ) * 2 - 1;
        horizonNoise *= step ( _Threshold, abs ( horizonNoise ) ) * _Amount;
        float shake = ( tex2D ( _NoiseTex, float2 ( _Time.x, 2 ) - 0.5 ) ) * _Shake;
        float drift = sin ( v + _DriftTime ) * _DriftAmount;
        half4 color = tex2D ( _MainTex, frac ( float2 ( u + jitter + shake, v ) ) );
        half4 color1 = tex2D ( _MainTex, frac ( float2 ( u + jitter + shake + drift, v ) ) );
        return half4 ( color.r, color.g, color1.b, 1 );
    }

Конечный результат выглядит следующим образом:

Постскриптум

Выше приведены визуальные эффекты, которые я делал в последние дни. Я как можно скорее выложу код в Github. Пожалуйста, указывайте в комментариях, если что-то надо улучшить.

Ссылки:

[1] Путешествие по графическому конвейеру LOL (12 января 2017 г.), https://engineering.riotgames.com/news/trip-down-lol-graphics-pipeline Получено 19:24, 12 марта 2017 г. в блоге Riot Games.

[2] https://docs.unity3d.com/Manual/script-EdgeDetectEffect.html

[3] Оператор Собеля в Википедии. Получено 01:45, 13 марта 2017 г., с https://en.wikipedia.org/w/index.php?title=Sobel_operator&oldid. = 768707694

[4] https://docs.unity3d.com/Manual/SL-GrabPass.html

[5] https://blogs.unity3d.com/cn/2011/09/08/special-effects-with-depth-talk-at-siggraph/

Оригинальный текст: https://alphamistral.github.io/2017/03/12/essay5

Понравилась статья? Поделиться с друзьями:
Автор natalya
Переводит для Вас самые интересные статьи про разработку игр. По образованию физик-программист. Техническими переводами начала подрабатывать еще на старших курсах и постепенно это переросло в основное занятие. Интересуется гуманитарными технологиями, пробует себя в журналистике.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *