Маленькие трюки DirectX и HLSL

Привет, Хабр!
Решил написать статью-заметку о небольших трюках, которые использую в своем скромном движке. Это скорее заметка самому-себе, и матёрые программисты лишь усмехнутся, но, думаю, новичкам она может пригодится.

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 буфера тема для отдельной статьи).
Сравнительные скрины
010807ca11d24bc3b683cbcbd1e9b0a6.png
b5fc14e3941a496a8e92792c13731daf.png

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;

Сравнительные скрины
bce21fb4341a4408b99025f2a2bbe7d3.png
c820068c7e9d4ffe96c0ad2f418444e4.png

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;
}

Вот собственно и вся чёртова дюжина, надеюсь материал окажется кому-то полезен.

Комментарии (0)

© Habrahabr.ru