Аналогов подобных теней для точечного источника света (Pointlight с эффектом размытия на расстоянии, имитирующий arealight) в компьютерных играх я почему-то до сих пор не встречал. Везде — либо полностью запечённые тени, либо «лампочки» вообще без теней, максимум — обыкновенная PCF-фильтрация. Хотя для направленного солнечного света уже давно применяются PCSS-тени (GTA5, например). В Unreal есть интересный алгоритм сродни рейтрейсингу, который рисует красивые arealight-тени, но только для статической геометрии (требуется генерация дополнительных объёмов). В Unity же всё совсем плохо — мягко фильтруется только солнечный свет, а «прожекторы» и «лампочки» в пролёте.
Если на «прожекторах» в Unity висит хоть какая-то худо-бедно билинейная фильтрация, то на точечных «лампочках» такой вот ужас:
Неужели, добавить обыкновенную PCF-фильтрацию — это такой удар по производительности? Даже если и удар, то почему не сделать возможность включения этой фильтрации опционально?
Как выяснилось, удара по производительности нет. В Unity 5 действует технология differed lighting (отложенное освещение), и чем больше в кадре источников света с динамической тенью, тем хуже производительность. А уж какая фильтрация у этих теней — не столь важно. Осуществляется одно и то же число проходов, обрабатывается столько же пикселей, этот момент как раз и требует оптимизации сцены. А число выборок из кубической текстуры глубины «лампочки» пусть и влияет на производительность, но очень незначительно (в районе пары FPS).
Также стоит сказать, что размывать полученную тень image-эффектом не получится. Нет никакой прослойки между выборкой из текстуры глубины и наложением света, такая уж система в Unity. А жаль, можно было бы в чём-то выиграть.
Итак, меняем алгоритм
Создавать специальные материалы ради мягких теней кропотливо, вносить изменения в имеющиеся шейдеры скриптом (как это сделано в ShadowSoftener) — тоже как-то не очень удобно. Гораздо быстрее и практичнее применить тени сразу ко всем шейдерам в проекте (встроенным, рукописным, скачанным), изменив всего один файл «UnityShadowLibrary.cginc», который находится в директории редактора: "...Unity5EditorDataCGIncludes".
Находим этот кусочек, который отвечает за тень от точечного источника света:
#if defined (SHADOWS_SOFT)
float z = 1.0/128.0;
float4 shadowVals;
shadowVals.x = SampleCubeDistance (vec+float3( z, z, z));
shadowVals.y = SampleCubeDistance (vec+float3(-z,-z, z));
shadowVals.z = SampleCubeDistance (vec+float3(-z, z,-z));
shadowVals.w = SampleCubeDistance (vec+float3( z,-z,-z));
half4 shadows = (shadowVals < mydist.xxxx) ? _LightShadowData.rrrr : 1.0f;
return dot(shadows,0.25);
#else
На место него можно поставить любой понравившийся нам алгоритм, но сначала разберём входные параметры:
vec — четырёхмерный вектор. x,y и z — это направление от «лампочки» к пикселю. Делая выборку по этому значению с помощью SampleCubeDistance, мы получаем расстояние затенившего объекта. Если расстояние меньше длины vec, тень на пиксель не падает. (mydist — то самое расстояние, но это не входной праметр, он вычисляется выше в этом же файле) В каждой выборке координата смещается на фиксированную величину, создавая эффект неоднородной границы. Не смейтесь. Профессиональные программисты, разрабатывающие Unity, называют это «мягкой тенью».
_LightShadowData.r — это значение ползунка, настраивает яркость тени. Можно воспользоваться им, например, для изменения степени размытия тени или для изменения какого-то другого параметра, чтобы отлаживать шейдер непосредственно в редакторе. К сожалению, я так и не выяснил, на что влияют другие компоненты _LightShadowData. Видимо, для точечного источника света больше параметров нет.
Возвращаем из функции яркость (0 до 1.0), т.е. множитель, который во всех шейдерах действует под именем atten (attenuation), поэтому изменённая тень будет действовать тоже во всех шейдерах, поддерживающих затенение.
Чтобы увидеть изменения, найдите папку своего проекта, в ней – папку «Library». Удаляем из этой папки директорию «ShaderCache» и перезапускаем Unity. Я тоже перезапускал Unity после каждого редактирования шейдера, это нервировало и усложняло отладку.
Вот и всё.
Я попробовал вот такой вариант, замазав края неказистым дизерингом:
#if defined (SHADOWS_SOFT)
// Чем меньше, тем тень размытестее
float downscale = 32.0f;
// Случайный вектор
const float3 rndseed = float3(12.9898,78.233,45.5432);
float3 randomvec = float3( dot(vec,rndseed) , dot(vec.yzx,rndseed) , dot(vec.zxy,rndseed) );
randomvec = frac(sin(randomvec) * 43758.5453);
// Вот эти вектора для смещений
float3 xvec = normalize(cross(vec,randomvec));
float3 yvec = normalize(cross(vec,xvec));
float3 vec1 = xvec / downscale;
float3 vec2 = yvec / downscale;
float4 shadowVals;
// Выборки из кубмапы
shadowVals.x = SampleCubeDistance (vec+vec1);
shadowVals.y = SampleCubeDistance (vec+vec2);
shadowVals.z = SampleCubeDistance (vec-vec1);
shadowVals.w = SampleCubeDistance (vec-vec2);
// Смешиваем
half4 shadows = (shadowVals < mydist.xxxx) ? _LightShadowData.rrrr : 1.0f;
return dot(shadows,0.25);
#else
Выборок столько же, сколько и в жуткой каноничной реализации, но, по крайней мере, пиксели не мозолят глаза:
Не долго думая (но долго отлаживая) сделал такую вот реализацию PCF4x4 с «ручной» билинейной фильтрацией (в шейдеры Unity можно, конечно, вставлять команды DirectX11, среди которых есть и билинейная фильтрация теней кубмапа, но я пошёл по пути универсальности и сделал дополнительный ряд выборок для «ручного» сглаживания, получилось не 16 выборок, а 25).
Или даже вот так:
Это не честный PCSS, а более быстрая и простая реализация. Максимальное размытие — это то же самое PCF4x4. А чёткая тень вблизи затенителя достигается увеличением резкости. Между прочим, эффект shapen тоже много где применяется. Здесь же у нас симбиоз, с помощью которого можно добиться красивого эффекта.
Выше упомянутый бегунок настраивает угол перехода мягкой тени в резкую. Этот параметр можно подбирать разным образом для разных «лампочек», исходя из размеров помещений, в котором они находятся.
Код последней реализации я поместил под кат. Он немного громоздок и неопрятен, ведь я не использовал двумерный цикл выборок, а прописывал их вручную, чтобы снизить число команд и количество используемых переменных, а заодно применить операции с векторами там, где можно вычислить сразу несколько переменных. Плюс я принципиально не использовал условные переходы, заменив их математикой. Поэтому смотрите на свой страх и риск.
inline half UnitySampleShadowmap (float3 vec)
{
float mydist = length(vec) * _LightPositionRange.w;
mydist *= 0.97; // bias
const float downscale = 128.0f;
const float sat_mult = 312.0f;
#if defined (SHADOWS_SOFT)
#define shadow_close_scalefactor (_LightShadowData.r + 0.001f)
// Виртуальные оси и их преобразование в настоящие
#define xvec main_axis.zxy
#define yvec main_axis.yzx
#define VIRTUAL_COORD(x,y) (ceilvec + xvec*x + yvec*y)
// Нам надо узнать, вдоль какой плоскости направить изначальный вектор
// (ось вперёд для правильного направления пикселей)
half3 main_axis = abs(vec)*1666.666f;
main_axis = normalize(clamp(main_axis.xyz - main_axis.yzx,0.0f,1.0f)*clamp(main_axis.xyz - main_axis.zxy,0.0f,1.0f));
// Упираем вектор в кубмап
vec /= abs(dot(vec,main_axis));
// Это центр виртуального пикселя, относительно которого будет вестись интерполяция цвета
fixed3 ceilvec = ceil(vec*downscale) / downscale;
// От нуля до единицы - значения для интерполяции между 4-мя положениями
fixed4 lerp_delta;
vec = (ceilvec - vec) * downscale;
lerp_delta.x = dot(vec * xvec,1.0f);
lerp_delta.y = dot(vec * yvec,1.0f);
lerp_delta.z = 1.0f - lerp_delta.x;
lerp_delta.w = 1.0f - lerp_delta.y;
// Подготовка к выборкам
main_axis /= downscale;
ceilvec -= (xvec + yvec)*0.5f;
//Переменные и паттерны для выборки
float4 shadowVals, distance_sums, distancesides; fixed4 shadowsides, shadow_sums, distancesides_nums, distance_nums;
#define DISTANCE_COMPARE_X4(sum,distance_sum,distance_num) shadowVals = mydist.xxxx - shadowVals; sum = dot(clamp(shadowVals *sat_mult,0.0f,1.0f),1.0f); distance_sum = dot(clamp(shadowVals, 0.0f,100.0f),1.0f); distance_num = dot(clamp(shadowVals *sat_mult,0.0f,1.0f),1.0f)
#define DISTANCE_COMPARE_X3(sum,distance_sum,distance_num) shadowVals = mydist.xxxx - shadowVals; sum = dot(clamp(shadowVals.xyz*sat_mult,0.0f,1.0f),1.0f); distance_sum = dot(clamp(shadowVals.xyz,0.0f,100.0f),1.0f); distance_num = dot(clamp(shadowVals.xyz*sat_mult,0.0f,1.0f),1.0f)
#define DISTANCE_COMPARE_X1(sum,distance_sum,distance_num) shadowVals.x = mydist - shadowVals.x; sum = clamp(shadowVals.x *sat_mult,0.0f,1.0f); distance_sum = clamp(shadowVals.x, 0.0f,100.0f); distance_num = clamp(shadowVals.x *sat_mult,0.0f,1.0f)
// Выборка значений из центральной области - для всех положений одинакова
// Первые 4 пикселя
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(-1.0f,-1.0f));
shadowVals.y = SampleCubeDistance (VIRTUAL_COORD(0.0f,-1.0f));
shadowVals.z = SampleCubeDistance (VIRTUAL_COORD(1.0f,-1.0f));
shadowVals.w = SampleCubeDistance (VIRTUAL_COORD(1.0f,0.0f));
DISTANCE_COMPARE_X4(shadowsides.x,distancesides.x,distancesides_nums.x);
// Вторые 4 пикселя
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(-1.0f,-0.0f));
shadowVals.y = SampleCubeDistance (VIRTUAL_COORD(-1.0f,1.0f));
shadowVals.z = SampleCubeDistance (VIRTUAL_COORD(0.0f,1.0f));
shadowVals.w = SampleCubeDistance (VIRTUAL_COORD(1.0f,1.0f));
DISTANCE_COMPARE_X4(shadowsides.y,distancesides.y,distancesides_nums.y);
// Центральный пиксель
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(0.0f,0.0f));
DISTANCE_COMPARE_X1(shadowsides.z,distancesides.z,distancesides_nums.z);
// Раскладываем суммы по
shadow_sums = dot(shadowsides.xyz,1.0f).xxxx;
distance_sums = dot(distancesides.xyz,1.0f).xxxx;
distance_nums = dot(distancesides_nums.xyz,1.0f).xxxx + fixed4(0.01f,0.01f,0.01f,0.01f);
// Выборка значений для индивидуальных областей
// Лево
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(-2.0f,-1.0f));
shadowVals.y = SampleCubeDistance (VIRTUAL_COORD(-2.0f,0.0f));
shadowVals.z = SampleCubeDistance (VIRTUAL_COORD(-2.0f,1.0f));
DISTANCE_COMPARE_X3(shadowsides.x,distancesides.x,distancesides_nums.x);
// Низ
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(-1.0f,-2.0f));
shadowVals.y = SampleCubeDistance (VIRTUAL_COORD(0.0f,-2.0f));
shadowVals.z = SampleCubeDistance (VIRTUAL_COORD(1.0f,-2.0f));
DISTANCE_COMPARE_X3(shadowsides.y,distancesides.y,distancesides_nums.y);
// Право
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(2.0f,-1.0f));
shadowVals.y = SampleCubeDistance (VIRTUAL_COORD(2.0f,0.0f));
shadowVals.z = SampleCubeDistance (VIRTUAL_COORD(2.0f,1.0f));
DISTANCE_COMPARE_X3(shadowsides.z,distancesides.z,distancesides_nums.z);
// Верх
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(-1.0f,2.0f));
shadowVals.y = SampleCubeDistance (VIRTUAL_COORD(0.0f,2.0f));
shadowVals.z = SampleCubeDistance (VIRTUAL_COORD(1.0f,2.0f));
DISTANCE_COMPARE_X3(shadowsides.w,distancesides.w,distancesides_nums.w);
// Раскладываем суммы по соответствующим секциям
shadow_sums += (shadowsides.xzxz + shadowsides.yyww);
distance_sums += (distancesides.xzxz + distancesides.yyww);
distance_nums += distancesides_nums.xzxz + distancesides_nums.yyww;
// Угловые точки
shadowVals.x = SampleCubeDistance (VIRTUAL_COORD(-2.0f,-2.0f));
shadowVals.y = SampleCubeDistance (VIRTUAL_COORD(2.0f,-2.0f));
shadowVals.z = SampleCubeDistance (VIRTUAL_COORD(-2.0f,2.0f));
shadowVals.w = SampleCubeDistance (VIRTUAL_COORD(2.0f,2.0f));
// Считаем вручную, ибо нефиг дефайны плодить ради одного вызова
shadowVals = mydist.xxxx - shadowVals;
shadow_sums += clamp(shadowVals*sat_mult,0.0f,1.0f);
distance_sums += clamp(shadowVals,0.0f,1.0f);
distance_nums += clamp(shadowVals*sat_mult,0.0f,1.0f);
// Интерполируем между четырьмя позициями
shadow_sums.x = dot(shadow_sums * lerp_delta.xzxz * lerp_delta.yyww, 1.0f) / 16.0f;
distance_sums.x = dot(clamp((distance_sums/distance_nums),0.0f,1.0f) * lerp_delta.xzxz * lerp_delta.yyww, 1.0f);
// Увеличиваем констраст вблизи источника тени
fixed contrastfactor = 1.0f - clamp(distance_sums.x/shadow_close_scalefactor,0.0f,1.0f);
shadow_sums.x = clamp((shadow_sums.x - 0.5f) * (1.0f + contrastfactor*4.0f)+0.5f,0.0f,1.0f);
return 1.0f - shadow_sums.x;
#else
// Тень с дизерингом и 4 выборками для упрощённой детализации
vec = normalize(vec) * 0.5f; // Компенсация размера тени
const float3 rndseed = float3(12.9898,78.233,45.5432);
float3 randomvec = float3( dot(vec,rndseed) , dot(vec.yzx,rndseed) , dot(vec.zxy,rndseed) );
randomvec = frac(sin(randomvec) * 43758.5453);
float3 vec1 = normalize(cross(vec,randomvec)) / downscale;
float3 vec2 = normalize(cross(vec,vec1)) / downscale;
float4 shadowVals;
shadowVals.x = SampleCubeDistance (vec+vec1);
shadowVals.y = SampleCubeDistance (vec+vec2);
shadowVals.z = SampleCubeDistance (vec-vec1);
shadowVals.w = SampleCubeDistance (vec-vec2);
shadowVals = mydist.xxxx - shadowVals;
fixed4 shadows = clamp(shadowVals*sat_mult,0.0f,1.0f);
return dot(shadows,0.25);
#endif
}
Если вас не устраивает конкретно этот подход, реализуйте в рамках Unity более подходящие алгоритмы. Буду только рад такой активности, ведь меня правда удручает застой в Unity в плане реалтайм теней, который тянется уже много лет.
P.S.: Больше всего мне хотелось бы услышать мнения тех, кто работает непосредственно с Unity и знает, что там можно, а что нельзя, хотя скорее всего будут пространные размышления об алгоритмах, впилить которые в движок не удастся ввиду его закрытой архитектуры. Но и к пространным размышлениям с радостью присоединюсь и поясню некоторые моменты.
Автор: LifeKILLED