Freigeben über


Arbeiten mit Shadern und Shaderressourcen

Befassen wir uns jetzt damit, wie Sie bei der Entwicklung Ihres Microsoft DirectX-Spiels für Windows 8 mit Shadern und Shaderressourcen arbeiten. Sie haben erfahren, wie Sie das Grafikgerät und die Grafikressourcen einrichten, und vielleicht haben Sie sogar mit dem Ändern der Pipeline begonnen. Sehen wir uns nun Pixel- und Vertexshader an.

Für den Fall, dass Sie mit Shadersprachen nicht vertraut sind, werden diese hier kurz erläutert. Shader sind kleine Programme auf niedriger Ebene, die kompiliert und in bestimmten Phasen der Grafikpipeline ausgeführt werden. Ihre Spezialität sind sehr schnelle mathematische Gleitkommaoperationen. Die gängigsten Shaderprogramme sind:

  • Vertexshader: Wird für jeden Vertex in einer Szene ausgeführt. Dieser Shader arbeitet mit Vertexpufferelementen, die von der aufrufenden App bereitgestellt werden, und führt minimal zu einem 4-Komponenten-Positionsvektor, der in eine Pixelposition gerastert wird.
  • Pixelshader: Wird für jedes Pixel in einem Renderziel ausgeführt. Dieser Shader empfängt gerasterte Koordinaten aus früheren Shaderphasen (in den einfachsten Pipelines wäre dies der Vertexshader) und gibt eine Farbe (oder einen anderen 4-Komponenten-Wert) für diese Pixelposition zurück, die dann in ein Renderziel geschrieben wird.

Dieses Beispiel enthält sehr einfache Vertex- und Pixelshader, die nur Geometrie zeichnen, sowie komplexere Shader, die einfache Beleuchtungsberechnungen hinzufügen.

Shaderprogramme werden in Microsoft HLSL (High-Level Shader Language) geschrieben. Die HLSL-Syntax ähnelt C, umfasst aber keine Zeiger. Shaderprogramme müssen sehr kompakt und effizient sein. Wenn Ihr Shader zu viele Anweisungen kompiliert, kann er nicht ausgeführt werden, und es wird ein Fehler zurückgegeben. (Beachten Sie, dass die genaue Anzahl der zulässigen Anweisungen Teil der Direct3D-Featureebene ist.)

In Direct3D werden Shader nicht zur Laufzeit, sondern zusammen mit dem Rest des Programms kompiliert. Wenn Sie Ihre App mit Microsoft Visual Studio 2013 kompilieren, werden die HLSL-Dateien in CSO-Dateien kompiliert, die Ihre App vor dem Zeichnen in den GPU-Speicher laden und platzieren muss. Stellen Sie sicher, dass Sie diese CSO-Dateien beim Verpacken in Ihre App einschließen. Sie sind ebenso Ressourcen wie Gitter und Texturen.

Grundlegendes zur HLSL-Semantik

Vor dem Fortfahren sollten wir uns unbedingt einen Moment Zeit nehmen, um die HLSL-Semantik zu besprechen, da diese für neue Direct3D-Entwickler häufig verwirrend ist. Die HLSL-Semantik sind Zeichenfolgen, die einen Wert identifizieren, der zwischen der App und einem Shaderprogramm übergeben wird. Obwohl eine Vielzahl von Zeichenfolgen möglich ist, empfiehlt es sich, eine Zeichenfolge wie POSITION oder COLOR zu verwenden, die die Nutzung angibt. Sie weisen diese Semantik zu, wenn Sie einen Konstantenpuffer oder ein Eingabelayout erstellen. Sie können der Semantik auch eine Zahl zwischen 0 und 7 anfügen, sodass Sie für ähnliche Werte separate Register verwenden. Beispiel: COLOR0, COLOR1, COLOR2...

Bei einer Semantik mit dem Präfix „SV_“ handelt es sich um eine Systemwertsemantik, in die das Shaderprogramm schreibt und die nicht von Ihrem (in der CPU ausgeführten) Spiel selbst geändert werden kann. In der Regel enthält diese Semantik Werte, die Eingaben oder Ausgaben aus einer anderen Shaderphase in der Grafikpipeline sind oder vollständig von der GPU generiert werden.

Zudem weist die SV_-Semantik ein anderes Verhalten auf, wenn damit die Eingabe für eine Shaderphase oder die Ausgabe aus einer Shaderphase angegeben wird. Beispiel: SV_POSITION (Ausgabe) enthält die während der Vertexshaderphase transformierten Vertexdaten, und SV_POSITION (Eingabe) enthält die während der Rasterungsphase von der GPU interpolierten Pixelpositionswerte.

Hier ist gängige HLSL-Semantik aufgeführt:

  • POSITION(n) für Vertexpufferdaten. SV_POSITION stellt eine Pixelposition für den Pixelshader bereit und kann nicht von Ihrem Spiel geschrieben werden.
  • NORMAL(n) für normale Daten, die vom Vertexpuffer bereitgestellt werden.
  • TEXCOORD(n) für Textur-UV-Koordinatendaten, die für einen Shader bereitgestellt werden.
  • COLOR(n) für RGBA-Farbdaten, die für einen Shader bereitgestellt werden. Beachten Sie, dass sie genau wie Koordinatendaten behandelt werden, einschließlich der Interpolierung des Werts während der Rasterung. Anhand der Semantik können Sie einfach erkennen, dass es sich um Farbdaten handelt.
  • SV_Target[n] zum Schreiben aus einem Pixelshader in eine Zieltextur oder einen anderen Pixelpuffer.

Wir werden uns einige Beispiele für HLSL-Semantik ansehen, wenn wir das Beispiel betrachten.

Lesen aus den Konstantenpuffern

Jeder Shader kann aus einem Konstantenpuffer lesen, wenn dieser Puffer als Ressource an die Phase angefügt ist. In diesem Beispiel wird nur dem Vertexshader ein Konstantenpuffer zugewiesen.

Der Konstantenpuffer wird an zwei Stellen deklariert: im C++-Code und in den entsprechenden HLSL-Dateien, die darauf zugreifen.

Hier erfahren Sie, wie die Konstantenpufferstruktur im C++-Code deklariert wird.

typedef struct _constantBufferStruct {
    DirectX::XMFLOAT4X4 world;
    DirectX::XMFLOAT4X4 view;
    DirectX::XMFLOAT4X4 projection;
} ConstantBufferStruct;

Stellen Sie beim Deklarieren der Struktur für den Konstantenpuffer im C++-Code sicher, dass alle Daten ordnungsgemäß an den 16-Byte-Grenzen ausgerichtet sind. Die einfachste Möglichkeit hierfür ist die Verwendung von DirectXMath-Typen wie XMFLOAT4 oder XMFLOAT4X4, wie im Beispielcode gezeigt. Sie können falsch ausgerichtete Puffer auch vermeiden, indem Sie eine static_assert-Anweisung deklarieren:

// Assert that the constant buffer remains 16-byte aligned.
static_assert((sizeof(ConstantBufferStruct) % 16) == 0, "Constant Buffer size must be 16-byte aligned");

Diese Codezeile verursacht zur Kompilierungszeit einen Fehler, wenn ConstantBufferStruct nicht auf 16 Byte ausgerichtet ist. Weitere Informationen zum Ausrichten und Verpacken von Konstantenpuffern finden Sie unter Packregeln für konstante Variablen.

Hier erfahren Sie, wie der Konstantenpuffer in der Vertexshader-HLSL deklariert wird.

cbuffer ModelViewProjectionConstantBuffer : register(b0)
{
    matrix mWorld;      // world matrix for object
    matrix View;        // view matrix
    matrix Projection;  // projection matrix
};

Für alle Puffer, zum Beispiel Konstanten, Texturen oder Sampler, muss ein Register definiert werden, damit die GPU darauf zugreifen kann. Jede Shaderphase gestattet bis zu 15 Konstantenpuffer, und jeder Puffer kann bis zu 4.096 Konstantenvariablen enthalten. Die Syntax der Registerverwendungsdeklaration lautet wie folgt:

  • b*#*: Ein Register für einen Konstantenpuffer (cbuffer).
  • t*#*: Ein Register für einen Texturpuffer (tbuffer).
  • s*#*: Ein Register für einen Sampler. (Ein Sampler definiert das Nachschlageverhalten für Texel in der Texturressource.)

Beispielsweise kann die HLSL für einen Pixelshader mit einer Deklaration wie dieser eine Textur und einen Sampler als Eingabe verwenden.

Texture2D simpleTexture : register(t0);
SamplerState simpleSampler : register(s0);

Sie sind für das Registrieren konstanter Puffer zuständig – beim Einrichten der Pipeline fügen Sie einen Konstantenpuffer demselben Slot an, dem Sie sie in der HLSL-Datei zugewiesen haben. Im vorherigen Thema gibt der Aufruf von VSSetConstantBuffers beispielsweise "0" für den ersten Parameter an. Dadurch wird Direct3D angewiesen, die Konstantenpufferressource an Register 0 anzufügen, was der Zuordnung des Puffers zu register(b0) in der HLSL-Datei entspricht.

Lesen aus den Vertexpuffern

Der Vertexpuffer stellt dem Vertexshader die Dreiecksdaten für die Szenenobjekte bereit. Wie bei dem Konstantenpuffer wird die Vertexpufferstruktur im C++-Code mit ähnlichen Packregeln deklariert.

typedef struct _vertexPositionColor
{
    DirectX::XMFLOAT3 pos;
    DirectX::XMFLOAT3 color;
} VertexPositionColor;

Es gibt kein Standardformat für Vertexdaten in Direct3D 11. Stattdessen definieren wir unser eigenes Vertexdatenlayout mit einem Deskriptor. Die Datenfelder werden mithilfe eines Arrays aus D3D11_INPUT_ELEMENT_DESC-Strukturen definiert. Hier zeigen wir ein einfaches Eingabelayout, das dasselbe Vertexformat wie die vorhergehende Struktur beschreibt:

D3D11_INPUT_ELEMENT_DESC iaDesc [] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "COLOR", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};

hr = device->CreateInputLayout(
    iaDesc,
    ARRAYSIZE(iaDesc),
    bytes,
    bytesRead,
    &m_pInputLayout
    );

Wenn Sie dem Vertexformat beim Ändern des Beispielcodes Daten hinzufügen, müssen Sie auch das Eingabelayout aktualisieren. Ansonsten kann der Shader es nicht interpretieren. Sie können das Vertexlayout wie folgt ändern:

typedef struct _vertexPositionColorTangent
{
    DirectX::XMFLOAT3 pos;
    DirectX::XMFLOAT3 normal;
    DirectX::XMFLOAT3 tangent;
} VertexPositionColorTangent;

In diesem Fall ändern Sie die Eingabelayoutdefinition wie folgt.

D3D11_INPUT_ELEMENT_DESC iaDescExtended[] =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },

    { "TANGENT", 0, DXGI_FORMAT_R32G32B32_FLOAT,
    0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};

hr = device->CreateInputLayout(
    iaDesc,
    ARRAYSIZE(iaDesc),
    bytes,
    bytesRead,
    &m_pInputLayoutExtended
    );

Jeder der Definitionen von Eingabelayoutelementen wird eine Zeichenfolge vorangestellt, z. B. POSITION oder NORMAL, d. h. die Semantik, die wir weiter oben in diesem Thema behandelt haben. Diese ist vergleichbar mit einem Handle, mit dem die GPU dieses Element bei der Verarbeitung des Vertex identifizieren kann. Wählen Sie allgemeine, aussagekräftige Namen für Ihre Vertexelemente aus.

Genau wie beim Konstantenpuffer verfügt der Vertexshader über eine entsprechende Pufferdefinition für eingehende Vertexelemente. (Deshalb haben wir beim Erstellen des Eingabelayouts einen Verweis auf die Vertexshaderressource bereitgestellt. Direct3D überprüft das Datenlayout pro Vertex mit der Eingabestruktur des Shaders.) Beachten Sie, dass die Semantik zwischen der Eingabelayoutdefinition und dieser HLSL-Pufferdeklaration weitgehend übereinstimmt. COLOR wurde jedoch „0“ angefügt. Es ist nicht erforderlich, „0“ hinzuzufügen, wenn sie nur ein COLOR-Element im Layout deklariert haben. Es empfiehlt sich jedoch, falls Sie in Zukunft weitere Farbelemente hinzufügen möchten.

struct VS_INPUT
{
    float3 vPos   : POSITION;
    float3 vColor : COLOR0;
};

Übergeben von Daten zwischen Shadern

Shader akzeptieren Eingabetypen und geben bei der Ausführung Ausgabetypen aus ihren main-Funktionen zurück. Für den im vorherigen Abschnitt definierten Vertexshader war der Eingabetyp die VS_INPUT-Struktur, und wir haben ein entsprechendes Eingabelayout und eine C++-Struktur definiert. Ein Array dieser Struktur wird verwendet, um einen Vertexpuffer in der CreateCube-Methode zu erstellen.

Der Vertexshader gibt eine PS_INPUT-Struktur zurück, die mindestens die endgültige Vertexposition aus 4 Komponenten (float4) enthalten muss. Für diesen Positionswert muss die Systemwertsemantik SV_POSITION deklariert sein, damit die GPU über die Daten verfügt, die sie zum Ausführen des nächsten Zeichnungsschritts benötigt. Beachten Sie, dass keine 1:1-Entsprechung zwischen Vertexshaderausgabe und Pixelshadereingabe vorliegt. Der Vertexshader gibt eine Struktur für jeden erhaltenen Vertex zurück, der Pixelshader wird jedoch einmal für jedes Pixel ausgeführt. Das liegt daran, dass die Daten pro Vertex zuerst die Rasterungsphase durchlaufen. Diese Phase legt fest, welche Pixel die von Ihnen gezeichnete Geometrie abdecken, berechnet interpolierte Daten pro Vertex für jedes Pixel und ruft dann für jedes dieser Pixel einmal den Pixelshader auf. Interpolation ist das Standardverhalten beim Rastern von Ausgabewerten und ist insbesondere für die korrekte Verarbeitung von Ausgabevektordaten (beispielsweise Lichtvektoren oder Normalen und Tangenten pro Vertex) unerlässlich.

struct PS_INPUT
{
    float4 Position : SV_POSITION;  // interpolated vertex position (system value)
    float4 Color    : COLOR0;       // interpolated diffuse color
};

Überprüfen des Vertexshaders

Der Beispielvertexshader ist sehr einfach aufgebaut: Akzeptieren eines Vertex (Position und Farbe), Transformieren der Position von Modellkoordinaten in perspektivische projizierte Koordinaten und Zurückgeben der Position (zusammen mit der Farbe) an den Rasterizer. Beachten Sie, dass der Farbwert direkt zusammen mit den Positionsdaten interpoliert und für jedes Pixel ein anderer Wert bereitgestellt wird, obwohl der Vertexshader keine Berechnungen für den Farbwert ausgeführt hat.

VS_OUTPUT main(VS_INPUT input) // main is the default function name
{
    VS_OUTPUT Output;

    float4 pos = float4(input.vPos, 1.0f);

    // Transform the position from object space to homogeneous projection space
    pos = mul(pos, mWorld);
    pos = mul(pos, View);
    pos = mul(pos, Projection);
    Output.Position = pos;

    // Just pass through the color data
    Output.Color = float4(input.vColor, 1.0f);

    return Output;
}

Ein komplexerer Vertexshader, z. B. ein Shader, der die Vertices eines Objekts für die Phong-Schattierung einrichtet, könnte wie folgt aussehen. In diesem Fall nutzen wir die Tatsache, dass die Vektoren und Normalen interpoliert werden, um eine annähernd glatt aussehende Oberfläche zu erzielen.

// A constant buffer that stores the three basic column-major matrices for composing geometry.
cbuffer ModelViewProjectionConstantBuffer : register(b0)
{
    matrix model;
    matrix view;
    matrix projection;
};

cbuffer LightConstantBuffer : register(b1)
{
    float4 lightPos;
};

struct VertexShaderInput
{
    float3 pos : POSITION;
    float3 normal : NORMAL;
};

// Per-pixel color data passed through the pixel shader.

struct PixelShaderInput
{
    float4 position : SV_POSITION; 
    float3 outVec : POSITION0;
    float3 outNormal : NORMAL0;
    float3 outLightVec : POSITION1;
};

PixelShaderInput main(VertexShaderInput input)
{
    // Inefficient -- doing this only for instruction. Normally, you would
 // premultiply them on the CPU and place them in the cbuffer.
    matrix mvMatrix = mul(model, view);
    matrix mvpMatrix = mul(mvMatrix, projection);

    PixelShaderInput output;

    float4 pos = float4(input.pos, 1.0f);
    float4 normal = float4(input.normal, 1.0f);
    float4 light = float4(lightPos.xyz, 1.0f);

    // 
    float4 eye = float4(0.0f, 0.0f, -2.0f, 1.0f);

    // Transform the vertex position into projected space.
    output.gl_Position = mul(pos, mvpMatrix);
    output.outNormal = mul(normal, mvMatrix).xyz;
    output.outVec = -(eye - mul(pos, mvMatrix)).xyz;
    output.outLightVec = mul(light, mvMatrix).xyz;

    return output;
}

Überprüfen des Pixelshaders

Der in diesem Beispiel gezeigte Pixelshader umfasst wahrscheinlich die absolute Mindestmenge an Code, die Sie für einen Pixelshader verwenden können. Er übernimmt die interpolierten Pixelfarbdaten, die während der Rasterung generiert werden, und gibt sie als Ausgabe zurück, wo sie in ein Renderziel geschrieben werden. Unspektakulär!

PS_OUTPUT main(PS_INPUT In)
{
    PS_OUTPUT Output;

    Output.RGBColor = In.Color;

    return Output;
}

Der wichtige Teil ist die SV_TARGET-Systemwertsemantik für den Rückgabewert. Sie gibt an, dass die Ausgabe in das primäre Renderziel geschrieben werden soll. Dabei handelt es sich um den Texturpuffer, der für die Anzeige an die Swapchain übergeben wird. Dies ist für Pixelshader erforderlich, denn ohne die Farbdaten aus dem Pixelshader gäbe es in Direct3D nichts anzuzeigen.

Ein Beispiel für einen komplexeren Pixelshader zum Durchführen einer Phong-Schattierung könnte wie folgt aussehen. Da die Vektoren und Normalen interpoliert wurden, müssen sie nicht pro Pixel berechnet werden. Wir müssen sie jedoch aufgrund der Funktionsweise der Interpolation erneut normalisieren. Konzeptionell müssen wir den Vektor schrittweise von der Richtung bei Vertex A in die Richtung bei Vertex B drehen und dabei die Länge beibehalten. Stattdessen wird bei der Interpolation eine gerade Linie zwischen den beiden Vektorendpunkten überquert.

cbuffer MaterialConstantBuffer : register(b2)
{
    float4 lightColor;
    float4 Ka;
    float4 Kd;
    float4 Ks;
    float4 shininess;
};

struct PixelShaderInput
{
    float4 position : SV_POSITION;
    float3 outVec : POSITION0;
    float3 normal : NORMAL0;
    float3 light : POSITION1;
};

float4 main(PixelShaderInput input) : SV_TARGET
{
    float3 L = normalize(input.light);
    float3 V = normalize(input.outVec);
    float3 R = normalize(reflect(L, input.normal));

    float4 diffuse = Ka + (lightColor * Kd * max(dot(input.normal, L), 0.0f));
    diffuse = saturate(diffuse);

    float4 specular = Ks * pow(max(dot(R, V), 0.0f), shininess.x - 50.0f);
    specular = saturate(specular);

    float4 finalColor = diffuse + specular;

    return finalColor;
}

In einem anderen Beispiel verwendet der Pixelshader eigene Konstantenpuffer, die Licht- und Materialinformationen enthalten. Das Eingabelayout im Vertexshader würde auf Normalendaten erweitert, und in der Ausgabe dieses Vertexshaders werden transformierte Vektoren für den Vertex, das Licht und die Vertexnormale im Ansichtskoordinatensystem erwartet.

Wenn Sie über Texturpuffer und Sampler mit zugewiesenen Registern (t bzw. s) verfügen, können Sie auch im Pixelshader darauf zugreifen.

Texture2D simpleTexture : register(t0);
SamplerState simpleSampler : register(s0);

struct PixelShaderInput
{
    float4 pos : SV_POSITION;
    float3 norm : NORMAL;
    float2 tex : TEXCOORD0;
};

float4 SimplePixelShader(PixelShaderInput input) : SV_TARGET
{
    float3 lightDirection = normalize(float3(1, -1, 0));
    float4 texelColor = simpleTexture.Sample(simpleSampler, input.tex);
    float lightMagnitude = 0.8f * saturate(dot(input.norm, -lightDirection)) + 0.2f;
    return texelColor * lightMagnitude;
}

Shader sind sehr leistungsstarke Tools, mit deren Hilfe prozedurale Ressourcen wie Schattenkarten oder Rauschtexturen generiert werden können. Tatsächlich erfordern erweiterte Techniken, dass Sie sich Texturen abstrakter vorstellen, nicht als visuelle Elemente, sondern als Puffer. Sie enthalten Daten wie Höheninformationen oder andere Daten, die im endgültigen Pixelshaderdurchlauf oder in diesem bestimmten Frame als Teil eines mehrstufigen Effektdurchlaufs erfasst werden können. Mehrfachsampling ist ein leistungsstarkes Tool und die Grundlage vieler moderner visueller Effekte.

Nächste Schritte

An diesem Punkt sollten Sie mit DirectX 11 vertraut und dazu bereit sein, mit der Arbeit an Ihrem Projekt zu beginnen. Hier sind einige Links, über die Sie Antworten auf weitere mögliche Fragen zum Entwickeln mit DirectX und C++ finden:

Arbeiten mit DirectX-Geräteressourcen

Grundlegendes zur Direct3D 11-Renderingpipeline