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

в 11:41, , рубрики: hlsl DirectX SharpDX, разработка игр, метки:

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

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 буфера тема для отдельной статьи).

Сравнительные скрины

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

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;

Сравнительные скрины

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

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<GS_OUT> 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<GS_OUT> 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<GS_OUT> 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<PerInstanceData> 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<float3> 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;
}

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

Автор: DIMOSUS

Источник

Поделиться

* - обязательные к заполнению поля