Теперь, когда мы знаем основы комбинирования функций расстояний со знаком, можно использовать их для создания крутых вещей. В этом туториале мы применим их для рендеринга мягких двухмерных теней. Если вы пока не читали моих предыдущих туториалов о полях расстояний со знаком (signed distance fields, SDF), то крайне рекомендую их изучить, начав с туториала о создании простых фигур.
[В GIF возникли дополнительные артефакты при пересжатии.]
Базовая конфигурация
Я создал простую конфигурацию с комнатой, в ней используются техники, описанные в предыдущих туториалах. Ранее я не упоминал о том, что использовал для vector2 функцию abs
, чтобы отзеркалить позицию относительно осей x и y, а также о том, что инвертировал расстояние фигуры, чтобы поменять местами внутреннюю и внешнюю части.
Мы скопируем файл 2D_SDF.cginc из предыдущего туториала в одну папку с шейдером, который напишем в этом туториале.
Shader "Tutorial/037_2D_SDF_Shadows"{
Properties{
}
SubShader{
//the material is completely non-transparent and is rendered at the same time as the other opaque geometry
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}
Pass{
CGPROGRAM
#include "UnityCG.cginc"
#include "2D_SDF.cginc"
#pragma vertex vert
#pragma fragment frag
struct appdata{
float4 vertex : POSITION;
};
struct v2f{
float4 position : SV_POSITION;
float4 worldPos : TEXCOORD0;
};
v2f vert(appdata v){
v2f o;
//calculate the position in clip space to render the object
o.position = UnityObjectToClipPos(v.vertex);
//calculate world position of vertex
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
return o;
}
float scene(float2 position) {
float bounds = -rectangle(position, 2);
float2 quarterPos = abs(position);
float corner = rectangle(translate(quarterPos, 1), 0.5);
corner = subtract(corner, rectangle(position, 1.2));
float diamond = rectangle(rotate(position, 0.125), .5);
float world = merge(bounds, corner);
world = merge(world, diamond);
return world;
}
fixed4 frag(v2f i) : SV_TARGET{
float dist = scene(i.worldPos.xz);
return dist;
}
ENDCG
}
}
FallBack "Standard" //fallback adds a shadow pass so we get shadows on other objects
}
Если бы мы по-прежнему использовали технику визуализации из предыдущего туториала, то фигура бы выглядела так:
Простые тени
Для создания резких теней мы обходим пространство от позиции сэмпла к позиции источника света. Если мы находим на пути объект, то решаем, что пиксель должен быть затенён, а если беспрепятственно добираемся до источника, то говорим, что он не затенён.
Начинаем мы с вычисления базовых параметров луча. У нас уже есть исходная точка (позиция пикселя, который мы рендерим) и целевая точка (позиция источника освещения) для луча. Нам нужны длина и нормализованное направление. Направление можно получить, вычтя начало из конца и нормализовав результат. Длину можно получить, вычтя позиции и передав значение в метод length
.
float traceShadow(float2 position, float2 lightPosition){
float direction = normalise(lightPosition - position);
float distance = length(lightPosition - position);
}
Затем мы итеративно обходим луч в цикле. Мы зададим итерации цикла в объявлении define, и это позволит нам позже настроить максимальное количество итераций, а также позволит компилятору немного оптимизировать шейдер, развернув цикл.
В цикле нам нужна позиция, в которой мы сейчас находимся, поэтому мы объявляем её вне цикла с начальным значением 0. В цикле мы можем вычислить позицию сэмпла, сложив с базовой позицией продвижение луча, умноженное на направление луча. Затем мы сэмплируем функцию расстояния со знаком в только что вычисленной позиции.
// outside of function
#define SAMPLES 32
// in shadow function
float rayDistance = 0;
for(int i=0 ;i<SAMPLES; i++){
float sceneDist = scene(pos + direction * rayDistance);
//do other stuff and move the ray further
}
Затем выполним проверки, находимся ли мы уже в точке, где можно прекратить цикл. Если расстояние сцены функции расстояния со знаком близко к 1, то мы можем предположить, что луч блокирован фигурой, и вернуть 0. Если луч распространился дальше, чем расстояние до источника освещения, то можно предположить, что мы без коллизий достигли источника, и вернуть значение 1.
Если возврат не выполнен, то нужно вычислять следующую позицию сэмпла. Это делается прибавлением расстояния в сцене с продвижением луча. Причина этого в том, что расстояние в сцене даёт нам расстояние до ближайшей фигуры, поэтому если мы прибавим эту величину к лучу, то вероятно не сможем испустить луч дальше, чем ближайшая фигура, или даже за неё, что приведёт к протеканию теней.
В случае, если мы ни с чем не столкнулись и не достигли источника света к моменту завершения запаса сэмпла (цикл закончился), нам тоже нужно возвращать значение. Так как это в основном происходит рядом с фигурами, незадолго до того, как пиксель всё равно будет считаться затенённым, здесь мы используем возвращаемое значение 0.
#define SAMPLES 32
float traceShadows(float2 position, float2 lightPosition){
float2 direction = normalize(lightPosition - position);
float lightDistance = length(lightPosition - position);
float rayProgress = 0;
for(int i=0 ;i<SAMPLES; i++){
float sceneDist = scene(position + direction * rayProgress);
if(sceneDist <= 0){
return 0;
}
if(rayProgress > lightDistance){
return 1;
}
rayProgress = rayProgress + sceneDist;
}
return 0;
}
Чтобы использовать эту функцию, мы вызываем её в фрагментной функции с позицией пикселя и позицией источника освещения. Затем умножаем результат на любой цвет, чтобы смешать его с цветом источников освещения.
Также для визуализации геометрии я использовал технику, описанную в первом туториале про поля расстояний со знаком. Затем я просто добавил сложил и геометрию. Здесь мы можем просто использовать операцию сложения, а не выполнять линейную интерполяцию или подобные ей действия, потому что фигура имеет чёрный цвет везде, где фигуры нет, а тень имеет чёрный цвет везде, где фигура есть.
fixed4 frag(v2f i) : SV_TARGET{ float2 position = i.worldPos.xz;
float2 lightPos;
sincos(_Time.y, lightPos.x /*sine of time*/, lightPos.y /*cosine of time*/);
float shadows = traceShadows(position, lightPos);
float3 light = shadows * float3(.6, .6, 1);
float sceneDistance = scene(position);
float distanceChange = fwidth(sceneDistance) * 0.5;
float binaryScene = smoothstep(distanceChange, -distanceChange, sceneDistance);
float3 geometry = binaryScene * float3(0, 0.3, 0.1);
float3 col = geometry + light;
return float4(col, 1); }
Мягкие тени
Перейти от этих резких теней к более мягким и реалистичным достаточно просто. При этом шейдер не становится намного вычислительно затратнее.
Сначала мы просто получим расстояние до ближайшего объекта сцены для каждого сэмпла, который мы обходим, и выберем самый ближний. Тогда там, где мы раньше возвращали 1, можно будет возвращать расстояние до ближайшей фигуры. Чтобы яркость тени не была слишком высокой и не приводила к созданию странных цветов, мы пропустим её через метод saturate
, который ограничивает её интервалом от 0 до 1. Мы получаем минимум между текущей ближайшей фигурой и следующей после проверки, достигло ли уже распространение луча источника освещения, в противном случае мы можем взять сэмплы, которые проходят за источник освещения, и получить странные артефакты.
float traceShadows(float2 position, float2 lightPosition){
float2 direction = normalize(lightPosition - position);
float lightDistance = length(lightPosition - position);
float rayProgress = 0;
float nearest = 9999;
for(int i=0 ;i<SAMPLES; i++){
float sceneDist = scene(position + direction * rayProgress);
if(sceneDist <= 0){
return 0;
}
if(rayProgress > lightDistance){
return saturate(nearest);
}
nearest = min(nearest, sceneDist);
rayProgress = rayProgress + sceneDist;
}
return 0;
}
Первое, что мы заметим после этого — это странные «зубцы» в тенях. Они возникают потому, что расстояние от сцены до источника освещения меньше 1. Я разными способами пытался этому противодействовать, но не смог найти решения. Вместо этого мы можем реализовать резкость тени. Резкость будет ещё одним параметром в функции теней. В цикле мы умножаем расстояние в сцене на резкость, и тогда при резкости 2 мягкая, серая часть тени станет в два раза меньше. При использовании резкости источник освещения может находиться от фигуры на расстоянии не меньше 1, поделённой на резкость, иначе появятся артефакты. Поэтому если использовать резкость 20, то расстояние должно быть не меньше 0.05 единицы.
float traceShadows(float2 position, float2 lightPosition, float hardness){
float2 direction = normalize(lightPosition - position);
float lightDistance = length(lightPosition - position);
float rayProgress = 0;
float nearest = 9999;
for(int i=0 ;i<SAMPLES; i++){
float sceneDist = scene(position + direction * rayProgress);
if(sceneDist <= 0){
return 0;
}
if(rayProgress > lightDistance){
return saturate(nearest);
}
nearest = min(nearest, hardness * sceneDist);
rayProgress = rayProgress + sceneDist;
}
return 0;
}
//in fragment function
float shadows = traceShadows(position, lightPos, 20);
Минимизировав эту проблему, мы замечаем следующее: даже в областях, которые не должны быть затенены, всё равно рядом со стенами видно ослабление. Кроме того, мягкость тени кажется одинаковой для всей тени, а не резкой рядом с фигурой и более мягкой при отдалении от испускающей тень объекта.
Мы исправим это, разделив расстояние в сцене на распространение луча. Благодаря этому мы разделим расстояние на очень небольшие числа в месте начала луча, то есть мы всё равно получим высокие значения и красивую чёткую тень. Когда мы найдём ближайшую к лучу точку в последующих точках луча, ближайшая точка делится на большее число, что делает тень мягче. Так как это не совсем связано с кратчайшим расстоянием, мы переименуем переменную в shadow
.
Также мы внесём ещё одно незначительное изменение: так как мы делим на rayProgress, не стоит начинать с 0 (делить на ноль — это почти всегда плохая идеяdividing ). В качестве начала можно выбрать любое очень малое число.
float traceShadows(float2 position, float2 lightPosition, float hardness){
float2 direction = normalize(lightPosition - position);
float lightDistance = length(lightPosition - position);
float rayProgress = 0.0001;
float shadow = 9999;
for(int i=0 ;i<SAMPLES; i++){
float sceneDist = scene(position + direction * rayProgress);
if(sceneDist <= 0){
return 0;
}
if(rayProgress > lightDistance){
return saturate(shadow);
}
shadow = min(shadow, hardness * sceneDist / rayProgress);
rayProgress = rayProgress + sceneDist;
}
return 0;
}
Несколько источников освещения
В этой простой одношейдерной реализации самый лёгкий способ получения нескольких источников освещения — вычисление их по отдельности и сложение результатов.
fixed4 frag(v2f i) : SV_TARGET{
float2 position = i.worldPos.xz;
float2 lightPos1 = float2(sin(_Time.y), -1);
float shadows1 = traceShadows(position, lightPos1, 20);
float3 light1 = shadows1 * float3(.6, .6, 1);
float2 lightPos2 = float2(-sin(_Time.y) * 1.75, 1.75);
float shadows2 = traceShadows(position, lightPos2, 10);
float3 light2 = shadows2 * float3(1, .6, .6);
float sceneDistance = scene(position);
float distanceChange = fwidth(sceneDistance) * 0.5;
float binaryScene = smoothstep(distanceChange, -distanceChange, sceneDistance);
float3 geometry = binaryScene * float3(0, 0.3, 0.1);
float3 col = geometry + light1 + light2;
return float4(col, 1);
}
Исходники
Двухмерная SDF-библиотека (не изменилась, но используется здесь)
Двухмерные мягкие тени
Shader "Tutorial/037_2D_SDF_Shadows"{
Properties{
}
SubShader{
//the material is completely non-transparent and is rendered at the same time as the other opaque geometry
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}
Pass{
CGPROGRAM
#include "UnityCG.cginc"
#include "2D_SDF.cginc"
#pragma vertex vert
#pragma fragment frag
struct appdata{
float4 vertex : POSITION;
};
struct v2f{
float4 position : SV_POSITION;
float4 worldPos : TEXCOORD0;
};
v2f vert(appdata v){
v2f o;
//calculate the position in clip space to render the object
o.position = UnityObjectToClipPos(v.vertex);
//calculate world position of vertex
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
return o;
}
float scene(float2 position) {
float bounds = -rectangle(position, 2);
float2 quarterPos = abs(position);
float corner = rectangle(translate(quarterPos, 1), 0.5);
corner = subtract(corner, rectangle(position, 1.2));
float diamond = rectangle(rotate(position, 0.125), .5);
float world = merge(bounds, corner);
world = merge(world, diamond);
return world;
}
#define STARTDISTANCE 0.00001
#define MINSTEPDIST 0.02
#define SAMPLES 32
float traceShadows(float2 position, float2 lightPosition, float hardness){
float2 direction = normalize(lightPosition - position);
float lightDistance = length(lightPosition - position);
float lightSceneDistance = scene(lightPosition) * 0.8;
float rayProgress = 0.0001;
float shadow = 9999;
for(int i=0 ;i<SAMPLES; i++){
float sceneDist = scene(position + direction * rayProgress);
if(sceneDist <= 0){
return 0;
}
if(rayProgress > lightDistance){
return saturate(shadow);
}
shadow = min(shadow, hardness * sceneDist / rayProgress);
rayProgress = rayProgress + max(sceneDist, 0.02);
}
return 0;
}
fixed4 frag(v2f i) : SV_TARGET{
float2 position = i.worldPos.xz;
float2 lightPos1 = float2(sin(_Time.y), -1);
float shadows1 = traceShadows(position, lightPos1, 20);
float3 light1 = shadows1 * float3(.6, .6, 1);
float2 lightPos2 = float2(-sin(_Time.y) * 1.75, 1.75);
float shadows2 = traceShadows(position, lightPos2, 10);
float3 light2 = shadows2 * float3(1, .6, .6);
float sceneDistance = scene(position);
float distanceChange = fwidth(sceneDistance) * 0.5;
float binaryScene = smoothstep(distanceChange, -distanceChange, sceneDistance);
float3 geometry = binaryScene * float3(0, 0.3, 0.1);
float3 col = geometry + light1 + light2;
return float4(col, 1);
}
ENDCG
}
}
FallBack "Standard"
}
Это всего лишь один из множества примеров использования полей расстояний со знаком. Пока они довольно громоздки, потому что все фигуры необходимо прописывать в шейдере или передавать через свойства шейдера, но у меня есть кое-какие идеи о том, как сделать их более удобными для будущих туториалов.
Автор: PatientZero