Маленькие трюки DirectX и HLSL
Привет, Хабр!
Решил написать статью-заметку о небольших трюках, которые использую в своем скромном движке. Это скорее заметка самому-себе, и матёрые программисты лишь усмехнутся, но, думаю, новичкам она может пригодится.
Допустим в вертексном шейдере нам нужно повернуть нормаль (тангенту, бинормаль) вертекса и у нас есть мировая матрица 4×4. Но сдвиг, зашитый в матрицу, нам не нужен. Тогда просто приводим матрицу к 3×3:
Кстати, если вам нужно получить инверсную матрицу от матрицы поворота 3×3, и при этом она ортогональна, то достаточно ее просто транспонировать:
Или же можно обойтись и без этого, если вам нужно лишь получить трансформированный инверсной матрицей вектор — тогда достаточно изменить порядок умножения матрицы и вектора:
Вы наверняка знаете, что для доступа к элементу матрицы можно использовать запись типа:
Однако синтаксис позволяет получить сразу несколько значений из матрицы. Например получить перемещение из матрицы трансформации:
В DX11 есть замечательная возможность отправить на рендер вершины, не создавая для этого вершинный буфер. Код для C# и врапера SharpDX:
Здесь мы отправляем на рендер три вершины. А в шейдере, для примера, мы можем построить из них полноэкранный квад:
Еще одной полезной функцией является рендер без пиксельного шейдера. Это позволяет заметно оптимизировать время на рендер в некоторых случаях. Например при препасе глубины, или при рендере теней. Мы просто не устанавливаем пиксельный шейдер в наш пайплайн:
Или же:
В обоих случаях пиксельный шейдер не будет выполнен, и в рендер таргет будет записана интерполированная в вертексном шейдере глубина.
Можно пойти дальше, и установить пиксельный шейдер, который ни чего не возвращает:
В данном случае производится проверка на альфатест. И если он не пройден, то пиксель будет выброшен из конвейера. Если же все в порядке, то, аналогично предыдущему случаю, в рендертаргет будет записана интерполированная вертексным шейдером глубина.
В DX10/11 появилась замечательная возможность аппаратно, с помощью MSAA, сглаживать альфатест. Если простыми словами, то это возможность в пиксельном шейдере самостоятельно указать, сколько семплов каждого пикселя MSAA рендертаргета прошли тест.
У меня учет альфатеста происходит только на стадии Z-препаса. После финального прохода нам достаточно выполнить резолв MSAA буфера и наш альфатест сгладится подобно обычной геометрии (правильный резолв HDR MSAA буфера тема для отдельной статьи).
Данная идея мне пришла после внедрения предыдущего пункта. Я выполняю суперскмплинг из текстуры нормали со смещением UV, вычисленным в скринспейсе. Так как я использую подход Forward+ c Z-препасом, то такая операция стоит минимально.
Что бы избежать артефактов освещения, для даблсайд треугольников нужно инвертировать нормаль, если мы смотрим на обратную их сторону:
Сам такой возможностью не пользуюсь, так как есть сомнения относительно ее производительности, однако кому-то может оказаться полезным:
С появлением геометрических шейдеров стало возможно делать различные оптимизации. Например ускорить рендер спрайтов. В видеокарту отправляются единичные вертексы, содержащие всю информацию о спрайте. В геометрическом шейдере из них конструируется полноценный спрайт:
По моим замерам такой подход дает порядка 20–30% ускорения как на слабом, так и мощном железе.
Аналогичный подход я использую для рисования линзовых эффектов. Только проверку видимости я провожу непосредственно перед конструированием спрайта. Сперва я проверяю как далеко эффект от краев экрана. Потом идет проверка на процент перекрытия эффекта объектами по буферу глубины. Если обе проверки пройдены, то конструирую спрайт:
Еще одним отличным примером может служить оптимизация Parallel-Split Shadow Maps геометрическими шейдерами из GPU Gems. В место того, что бы отправлять отдельный дип на рендеринг объекта в каждый сплит, мы можем силами видеокарты дублировать геометрию и отрендерить ее в разные рендертаргеты за один дип:
С переходом на DX11 рендерить с использование инстансинга стало гораздо проще. Теперь не обязательно создавать дополнительный поток вертексов с информацией для каждого инстанса. Можно просто указать сколько инстансов нам нужно:
А затем в шейдере получить для каждого инстанса его индекс и уже по нему определить необходимую дополнительную информацию:
Бывает полезно при работе с кубмапами.
И на закуску — простой способ оптимизации Гауссa. Используем аппаратную фильтрацию — производим выборку двух соседних пикселей, с заранее рассчитанным сдвигом между ними. Тем самым минимизируем общее количество выборок.
Решил написать статью-заметку о небольших трюках, которые использую в своем скромном движке. Это скорее заметка самому-себе, и матёрые программисты лишь усмехнутся, но, думаю, новичкам она может пригодится.
1. Матрицы в HLSL
Допустим в вертексном шейдере нам нужно повернуть нормаль (тангенту, бинормаль) вертекса и у нас есть мировая матрица 4×4. Но сдвиг, зашитый в матрицу, нам не нужен. Тогда просто приводим матрицу к 3×3:
output.Normal = mul(input.Normal.xyz, (float3x3)RotM);
Кстати, если вам нужно получить инверсную матрицу от матрицы поворота 3×3, и при этом она ортогональна, то достаточно ее просто транспонировать:
float3х3 invMat = transpose(Mat);
Или же можно обойтись и без этого, если вам нужно лишь получить трансформированный инверсной матрицей вектор — тогда достаточно изменить порядок умножения матрицы и вектора:
float3 outVector = mul((float3x3)RotM, inVector.xyz);
Вы наверняка знаете, что для доступа к элементу матрицы можно использовать запись типа:
float value = World._m30;
Однако синтаксис позволяет получить сразу несколько значений из матрицы. Например получить перемещение из матрицы трансформации:
float3 objPosition = World._m30_m31_m32;
2. Рендер без вертексного буфера
В DX11 есть замечательная возможность отправить на рендер вершины, не создавая для этого вершинный буфер. Код для C# и врапера SharpDX:
System.IntPtr n_IntPtr = new System.IntPtr(0);
device.ImmediateContext.InputAssembler.InputLayout = null;
device.ImmediateContext.InputAssembler.SetVertexBuffers(0, 0, n_IntPtr, n_IntPtr, n_IntPtr);
device.ImmediateContext.InputAssembler.SetIndexBuffer(null, Format.R32_UInt, 0);
device.ImmediateContext.Draw(3, 0);
Здесь мы отправляем на рендер три вершины. А в шейдере, для примера, мы можем построить из них полноэкранный квад:
struct VertexInput
{
uint VertexID : SV_VertexID;
};
struct PixelInput
{
float4 Position : SV_POSITION;
};
PixelInput DefaultVS(VertexInput input)
{
PixelInput output = (PixelInput)0;
uint id = input.VertexID;
float x = -1, y = -1;
x = (id == 2) ? 3.0 : -1.0;
y = (id == 1) ? 3.0 : -1.0;
output.Position = float4(x, y, 1.0, 1.0);
return output;
}
3. Рендер без пиксельного шейдера
Еще одной полезной функцией является рендер без пиксельного шейдера. Это позволяет заметно оптимизировать время на рендер в некоторых случаях. Например при препасе глубины, или при рендере теней. Мы просто не устанавливаем пиксельный шейдер в наш пайплайн:
pass GS_PSSM
{
SetVertexShader(CompileShader(vs_5_0, ShadowMapVS()));
SetGeometryShader(CompileShader(gs_5_0, ShadowMapGS()));
SetPixelShader(NULL);
SetBlendState(NoBlending, float4(0.0f, 0.0f, 0.0f, 0.0f), 0xFFFFFFFF);
SetDepthStencilState(EnableDepth, 0);
}
Или же:
device.ImmediateContext.PixelShader.Set(null);
В обоих случаях пиксельный шейдер не будет выполнен, и в рендер таргет будет записана интерполированная в вертексном шейдере глубина.
Можно пойти дальше, и установить пиксельный шейдер, который ни чего не возвращает:
void ZPrepasPS(PixelInputZPrePass input)
{
float4 albedo = AlbedoMap.Sample(Aniso, input.UV.xy);
if (albedo.w < AlphaTest.x)
discard;
}
В данном случае производится проверка на альфатест. И если он не пройден, то пиксель будет выброшен из конвейера. Если же все в порядке, то, аналогично предыдущему случаю, в рендертаргет будет записана интерполированная вертексным шейдером глубина.
4. Alpha to coverage
В DX10/11 появилась замечательная возможность аппаратно, с помощью MSAA, сглаживать альфатест. Если простыми словами, то это возможность в пиксельном шейдере самостоятельно указать, сколько семплов каждого пикселя MSAA рендертаргета прошли тест.
static const float2 MSAAOffsets8[8] =
{
float2(0.0625, -0.1875), float2(-0.0625, 0.1875),
float2(0.3125, 0.0625), float2(-0.1875, -0.3125),
float2(-0.3125, 0.3125), float2(-0.4375, -0.0625),
float2(0.1875, 0.4375), float2(0.4375, -0.4375)
};
void ZPrepasPSMS8(PixelInputZPrePass input, out uint coverage : SV_Coverage)
{
coverage = 0;
[branch]
if (AlphaTest.x <= 1 / 255.0)
coverage = 255;
else
{
float2 tc_ddx = ddx(input.UV.xy);
float2 tc_ddy = ddy(input.UV.xy);
[unroll]
for (int i = 0; i < 8; i++)
{
float2 texelOffset = MSAAOffsets8[i].x * tc_ddx + v2MSAAOffsets8[i].y * tc_ddy;
float temp = AlbedoMap.Sample(Aniso, input.UV.xy + texelOffset).w;
if (temp >= 0.5)
coverage |= 1 << i;
}
}
}
У меня учет альфатеста происходит только на стадии Z-препаса. После финального прохода нам достаточно выполнить резолв MSAA буфера и наш альфатест сгладится подобно обычной геометрии (правильный резолв HDR MSAA буфера тема для отдельной статьи).
Сравнительные скрины
5. Экранный антиальясинг нормалей
Данная идея мне пришла после внедрения предыдущего пункта. Я выполняю суперскмплинг из текстуры нормали со смещением UV, вычисленным в скринспейсе. Так как я использую подход Forward+ c Z-препасом, то такая операция стоит минимально.
static const float2 MSAAOffsets4[4] =
{
float2(-0.125, -0.375), float2(0.375, -0.125),
float2(-0.375, 0.125), float2(0.125, 0.375)
};
float3 ONormal = float3(0,0,0);
float2 tc_ddx = ddx(input.UV.xy);
float2 tc_ddy = ddy(input.UV.xy);
[unroll]
for (int i = 0; i < 4; i++)
{
float2 texelOffset = MSAAOffsets4[i].x * tc_ddx + MSAAOffsets4[i].y * tc_ddy;
float4 temp = NormalMap.Sample(Aniso, input.UV.xy + texelOffset*1.5);
ONormal += temp.ywy;
}
ONormal *= 0.25;
Normal = ONormal * 2.0f - 1.0f;
Сравнительные скрины
6. Нормали двухсторонней геометрии
Что бы избежать артефактов освещения, для даблсайд треугольников нужно инвертировать нормаль, если мы смотрим на обратную их сторону:
float3 FinalPS(PixelInput input, bool isFrontFace : SV_IsFrontFace) : SV_Target
{
input.Normal *= (1 - isFrontFace * 2);
...
7. Узнать размер текстуры в шейдере
Сам такой возможностью не пользуюсь, так как есть сомнения относительно ее производительности, однако кому-то может оказаться полезным:
Texture2D texture;
uint width, height;
texture.GetDimensions(width, height);
8. Спрайты геометрическими шейдерами
С появлением геометрических шейдеров стало возможно делать различные оптимизации. Например ускорить рендер спрайтов. В видеокарту отправляются единичные вертексы, содержащие всю информацию о спрайте. В геометрическом шейдере из них конструируется полноценный спрайт:
struct VS_IN
{
float4 Position : POSITION;
float4 UV : TEXCOORD0;
float4 Rotation : TEXCOORD1;
float4 Color : TEXCOORD2;
};
struct VS_OUT
{
float4 Position : SV_POSITION;
float4 UV : TEXCOORD0;
float4 Rotation : TEXCOORD1;
float4 Color : TEXCOORD2;
};
struct GS_OUT
{
float4 Position : SV_POSITION;
float2 TexCoord : TEXCOORD0;
float4 Color : TEXCOORD1;
}
VS_OUT GSSprite_VS( VS_IN Input )
{
VS_OUT Output;
float2 center = (Input.Position.xy + Input.Position.zw) * 0.5;
float2 size = (Input.Position.zw - center)*2.0;
Output.Position = float4(center, size);
Output.UV = Input.UV;
Output.Color = Input.Color;
Output.Rotation = Input.Rotation;
return Output;
}
[maxvertexcount(6)]
void GSSprite_GS(point VS_OUT In[1], inout TriangleStream triStream)
{
GS_OUT p0 = (GS_OUT) 0;
GS_OUT p1 = (GS_OUT) 0;
GS_OUT p2 = (GS_OUT) 0;
GS_OUT p3 = (GS_OUT) 0;
In[0].Position.xy = In[0].Position.xy * Resolution.zw * 2.0 - 1.0;
In[0].Position.y = -In[0].Position.y;
float2 r = float2(In[0].Rotation.x, -In[0].Rotation.y);
float2 t = float2(In[0].Rotation.y, In[0].Rotation.x);
p0.Position = float4(In[0].Position.xy + (-In[0].Position.z * r + In[0].Position.w * t) * Resolution.zw, 0.5, 1.0);
p0.TexCoord = In[0].UV.xy;
p0.Color = In[0].Color;
p1.Position = float4(In[0].Position.xy + (In[0].Position.z * r + In[0].Position.w * t) * Resolution.zw, 0.5, 1.0);
p1.TexCoord = In[0].UV.zy;
p1.Color = In[0].Color;
p2.Position = float4(In[0].Position.xy + (In[0].Position.z * r - In[0].Position.w * t) * Resolution.zw, 0.5, 1.0);
p2.TexCoord = In[0].UV.zw;
p2.Color = In[0].Color;
p3.Position = float4(In[0].Position.xy + (-In[0].Position.z * r - In[0].Position.w * t) * Resolution.zw, 0.5, 1.0);
p3.TexCoord = In[0].UV.xw;
p3.Color = In[0].Color;
triStream.Append(p0);
triStream.Append(p1);
triStream.Append(p2);
triStream.RestartStrip();
triStream.Append(p0);
triStream.Append(p2);
triStream.Append(p3);
triStream.RestartStrip();
}
По моим замерам такой подход дает порядка 20–30% ускорения как на слабом, так и мощном железе.
9. Lens Flare
Аналогичный подход я использую для рисования линзовых эффектов. Только проверку видимости я провожу непосредственно перед конструированием спрайта. Сперва я проверяю как далеко эффект от краев экрана. Потом идет проверка на процент перекрытия эффекта объектами по буферу глубины. Если обе проверки пройдены, то конструирую спрайт:
static const int2 offset[61] = {
int2( 0, 0), int2( 1, 0), int2( 1,-1), int2( 0,-1), int2(-1,-1), int2(-1, 0), int2(-1, 1), int2( 0, 1),
int2( 1, 1), int2( 2, 0), int2( 2,-1), int2( 2,-2), int2( 1,-2), int2( 0,-2), int2(-1, 2), int2(-2,-2),
int2(-2,-1), int2(-2, 0), int2(-2, 1), int2(-2, 2), int2(-1, 2), int2( 0, 2), int2( 1, 2), int2( 2, 2),
int2( 2, 1), int2( 3, 0), int2( 3,-1), int2( 1,-3), int2( 0,-3), int2(-1,-3), int2(-3,-1), int2(-3, 0),
int2(-3, 1), int2(-1,-3), int2( 0, 3), int2( 1, 3), int2( 3, 1), int2( 4, 0), int2( 4,-1), int2( 3,-2),
int2( 3,-3), int2(-2,-3), int2( 1,-4), int2( 0,-4), int2(-1,-4), int2(-2,-3), int2( 3,-3), int2(-3,-2),
int2(-4,-1), int2(-4, 0), int2(-4, 1), int2(-3, 2), int2(-3, 3), int2(-2, 3), int2(-1, 4), int2( 0, 4),
int2( 1, 4), int2( 2, 3), int2( 3, 3), int2( 3, 2), int2( 4, 1)};
[maxvertexcount(6)]
void GSSprite_GS(point VS_OUT In[1], inout TriangleStream triStream, uniform bool MSAA)
{
LensFlareStruct LFS = LensFlares[In[0].VertexID];
float4 Position = mul(LFS.Direction, ViewProection);
float3 NPos = Position.xyz / Position.w;
float dist = NPos.x - -1;
dist = min(1 - NPos.x, dist) * ScrRes.z; //Proportion
dist = min(NPos.y - -1, dist);
dist = min(1 - NPos.y, dist);
dist = min(NPos.z < 0.9, dist);
dist = saturate(dist * 20);
if (dist > 0)
{
float2 SPos = float2(NPos.x, -NPos.y) * 0.5 + 0.5;
int2 LPos = round(SPos * ScrRes.xy);
float v = 0;
if (MSAA)
{
for (int i = 0; i < 61; i++)
v += DepthTextureMS.Load(LPos + offset[i], 0) < NPos.z;
}
else
{
for (int i = 0; i < 61; i++)
v += DepthTexture.Load(uint3(LPos + offset[i], 0)) < NPos.z;
}
v = pow(v / 61.0, 2.0);
dist *= v;
if (dist > 0)
{
float2 Size = LFS.Size.xy * float2(ScrRes.w, 1);
Quad(triStream, Position, LFS.UV, Size * saturate(dist + 0.1), LFS.Color.xyz * dist);
}
}
}
10. Рендер PSSM с использованием геометрических шейдеров
Еще одним отличным примером может служить оптимизация Parallel-Split Shadow Maps геометрическими шейдерами из GPU Gems. В место того, что бы отправлять отдельный дип на рендеринг объекта в каждый сплит, мы можем силами видеокарты дублировать геометрию и отрендерить ее в разные рендертаргеты за один дип:
struct SHADOW_VS_OUT
{
float4 pos : SV_POSITION;
float4 UV1 : TEXCOORD0;
nointerpolation uint instId : SV_InstanceID;
};
struct GS_OUT
{
float4 pos : SV_POSITION;
float2 Texcoord : TEXCOORD0;
nointerpolation uint RTIndex : SV_RenderTargetArrayIndex;
};
[maxvertexcount(SPLITCOUNT * 3)]
void GS_RenderShadowMap(triangle SHADOW_VS_OUT In[3], inout TriangleStream triStream)
{
// For each split to render
for (int split = IstanceData[In[0].instId].Start; split <= IstanceData[In[0].instId].Stop; split++)
{
GS_OUT Out;
// Set render target index.
Out.RTIndex = split;
// For each vertex of triangle
[unroll(3)]
for (int vertex = 0; vertex < 3; vertex++)
{
// Transform vertex with split-specific crop matrix.
Out.pos = mul(In[vertex].pos, cropMatrix[split]);
Out.Texcoord = In[vertex].UV1.xy;
// Append vertex to stream
triStream.Append(Out);
}
// Mark end of triangle
triStream.RestartStrip();
}
}
11. Инстансинг
С переходом на DX11 рендерить с использование инстансинга стало гораздо проще. Теперь не обязательно создавать дополнительный поток вертексов с информацией для каждого инстанса. Можно просто указать сколько инстансов нам нужно:
device.ImmediateContext.DrawIndexedInstanced(IndicesCount, Meshes.Count, StartInd, 0, 0);
А затем в шейдере получить для каждого инстанса его индекс и уже по нему определить необходимую дополнительную информацию:
struct PerInstanceData
{
float4x4 WVP;
float4x4 World;
int Start;
int Stop;
int2 Padding;
};
StructuredBuffer IstanceData : register(t16);
PixelInput DefaultVS(VertexInput input, uint id : SV_InstanceID)
{
PixelInput output = (PixelInput) 0;
output.Position = mul(float4(input.Position.xyz, 1), IstanceData[id].WVP);
output.UV.xy = input.UV;
output.WorldPos = mul(float4(input.Position, 1), IstanceData[id].World).xyz;
...
12. Конвертирование 2D UV и индекса стороны в вектор для кубмапы
Бывает полезно при работе с кубмапами.
static const float3 offsetV[6] = { float3(1,1,1), float3(-1,1,-1), float3(-1,1,-1), float3(-1,-1,1), float3(-1,1,1), float3(1,1,-1) };
static const float3 offsetX[6] = { float3(0,0,-2), float3(0,0,2), float3(2,0,0), float3(2,0,0), float3(2,0,0), float3(-2,0,0) };
static const float3 offsetY[6] = { float3(0,-2,0), float3(0,-2,0), float3(0,0,2), float3(0,0,-2), float3(0,-2,0), float3(0,-2,0) };
float3 ConvertUV(float2 UV, int FaceIndex)
{
float3 outV = offsetV[FaceIndex] + offsetX[FaceIndex] * UV.x + offsetY[FaceIndex] * UV.y;
return normalize(outV);
}
13. Оптимизация фильтра Гауссa
И на закуску — простой способ оптимизации Гауссa. Используем аппаратную фильтрацию — производим выборку двух соседних пикселей, с заранее рассчитанным сдвигом между ними. Тем самым минимизируем общее количество выборок.
static const float Shift[4] = {0.4861161486, 0.4309984373, 0.3775380497, 0.3269038909 };
static const float Mult[4] = {0.194624, 0.189416, 0.088897, 0.027063 };
float3 GetGauss15(Texture2D Tex, float2 UV, float2 dx)
{
float3 rez = 0;
for (int i = 1; i < 4; i++)
rez += (Tex.Sample(LinSampler, UV + (Shift[i] + i*2)*dx ).xyz + Tex.Sample(LinSampler, UV - (Shift[i] + i*2)*dx).xyz) * Mult[i];
rez += Tex.Sample( LinSampler, UV ).xyz * 0.134598;
rez += (Tex.Sample( LinSampler, UV + dx ).xyz + Tex.Sample( LinSampler, UV - dx ).xyz )* 0.127325;
return rez;
}
Вот собственно и вся чёртова дюжина, надеюсь материал окажется кому-то полезен.