Erste Schritte mit Shadern unter XNA

Shader werfen bunte Schatten (Teil 2)
Kommentare

Die Funktion mul() ist dabei eine in der HLSL-Spezifikation festgelegte Funktion, die die Multiplikation zweier Matrizen miteinander bewerkstelligt. Der Pixel-Shader hat die Aufgabe, die eingehenden Vertexinformationen

Die Funktion mul() ist dabei eine in der HLSL-Spezifikation festgelegte Funktion, die die Multiplikation zweier Matrizen miteinander bewerkstelligt. Der Pixel-Shader hat die Aufgabe, die eingehenden Vertexinformationen in Pixelinformationen zu „konvertieren“. Unser denkbar primitives Beispiel retourniert einfach immer denselben Farbwert:

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
  // TODO: add your pixel shader code here.

  return float4(1, 0, 0, 1);
}  

Zu guter Letzt gruppiert man die berechnenden Funktionen in Arbeitspakete. Eine Technique stellt dabei einen Prozess dar, der in einen oder mehrere Unterprozesse gesplittet ist. Diese Unterprozesse, in HLSL auch Pass genannt, legen die anzuwendenden Pixel- und Vertex- Shader (jeweils einen) und einige Rendering-Optionen fest. Auf diese Art kann man mehrere Durchläufe der programmierbaren Pipeline hintereinanderschalten, was aber nicht Thema dieses Artikels sein soll:

technique Technique1
{
  pass Pass1
  {
    // TODO: set renderstates here.

    VertexShader = compile vs_2_0 VertexShaderFunction();
    PixelShader = compile ps_2_0 PixelShaderFunction();
  }
}  

Zu beachten ist hier nur noch, dass keine Funktion VertexShader oder PixelShader heißen darf. Wie alle anderen Ressourcen lädt man auch den Shader im Rahmen von oadContent. Die Referenz wandert in ein statisches Member der Game-Klasse, dessen Definition hier aus Platzgründen nicht abgedruckt wird:

protected override void LoadContent()
{
  spriteBatch = new SpriteBatch(GraphicsDevice);
  myFirstEffect = Content.Load("Shaders/FirstEffect");  

Hier sieht man eine sehr interessante und wichtige Information: In XNA dient die Klasse Effect zur Darstellung der diversen Shader. Das ist in der Tat etwas verwirrend. Warum man diese Klasse nicht auch Shader genannt hat, weiß man offenbar nur in Redmond.

BasicEffect raus!

Die zugegebenermaßen etwas theoretischen Ausführungen sind am leichtesten verständlich, wenn man sie in Aktion sieht. In der Klasse SmartModel finden wir die in Listing 4 dargestellte, sehr verdächtig „riechende“ Routine. An sich sieht das schon verdächtig nach dem Aktivieren eines Shaders aus: Ein Effekt wird geladen und mit einigen Parametern versehen.

Listing 4
foreach (ModelMeshPart mp in m.MeshParts)
{
  BasicEffect effect = (BasicEffect) mp.Effect;
  effect.Projection = _pro;
  effect.View = _view;
  effect.World = localWorld;
  effect.EnableDefaultLighting();
}  

Wir passen den inneren Teil der Routine ein wenig an, um anstatt des BasicEffect unseren ersten Shader zu verwenden (Listing 5). Die neue Version des Codes weist jedem Mesh des Modells statt dem BasicEffect unseren Shader zu. Zusätzlich geben wir dem Shader seine benötigten Parameter mit. Dabei dient das Parameters-Array als „Eingabefeld“. Die Variablennamen dürfen als Strings übergeben werden.

Listing 5
foreach (ModelMeshPart mp in m.MeshParts)
{
  mp.Effect = Game1.myFirstEffect;
  mp.Effect.Parameters["World"].SetValue(localWorld);
  mp.Effect.Parameters["View"].SetValue(_view);
  mp.Effect.Parameters["Projection"].SetValue(_pro);
}  

Wer sich über diese Übergabe von 3×3-Arrays wundert: Sie hat schon ihre Richtigkeit. Die „Korrelation“ des 3×3-Arrays auf das float4x4 erfolgt automatisch im Shader. Führen wir das Programm nun aus, sehen wir das in Abbildung 3 gezeigte Resultat. Übrigens: Im 3-D-Bereich ist während des Debuggens von Programmen die Verwendung schwarzer Hintergründe verpönt. Der Grund dafür ist, dass aufgrund eines Shader-Fehlers fehlerhaft gerenderte (also unbeleuchtete) Objekte schwarz erscheinen. Bei einem schwarzen Hintergrund bekäme man das natürlich nicht mit.

Abb. 3: Der Affensimulator, vorerst ganz in Rot
Abb. 3: Der Affensimulator, vorerst ganz in Rot

Im nächsten Schritt wird der Affe in einer anderen Farbe gerendert als die Bälle. Dazu kann man entweder zwei Shader anlegen oder aber den Pixel-Shader parametrieren. Aus didaktischer Sicht ist die zweite Methode attraktiver. Deshalb wird der Shader um eine Variable erweitert, die die auszugebende Farbe angibt: int myRedSetter;. Im Pixel-Shader fragen wir diese ab und ändern die Farbausgabe je nach Bedarf (Listing 6).

Listing 6
float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
  if(myRedSetter==0) 
return float4(1, 1, 1, 1);
else
  return float4(1, 0, 0, 1);
}  

Wenn man die Klasse SmartModel um ein Flag für die Farbe ergänzt und den Parameter korrekt setzt, erhält man beim Ausführen des Programms die in Abbildung 4 gezeigte Bildschirmausgabe.

Abb.4: Der Affe ist wieder weiß
Abb.4: Der Affe ist wieder weiß

In den Abbildungen 3 und 4 sind sowohl der Affe als auch der Ball erkennbar, allerdings nur aufgrund ihrer verschiedenen Umrisse und Farben. Hätte man diese Visual Cues nicht zur Hand, sähe man, wie es so schön heißt, alt aus, denn der primitive Shader berücksichtigt die Objektgeometrie nicht. Das klassische Lehrbuchbeispiel für einen solchen Shader ist der Diffuse Lighting Shader. Dabei handelt es sich um eine Shader-Routine, die von einer Quelle ausgehendes Licht an den Objekten quasi abprallen lässt. Um so etwas zu realisieren, benötigt man erstens die Position der Lichtquelle im Raum und zweitens die Lichtfarbe. Zusätzlich braucht man auch noch eine Hilfsmatrix. Deshalb wird der Shader um neue Parameter ergänzt:

float4x4 WorldInverseTranspose;
float3 DLVector = float3(-1000.0f, 1000.0f, 1000.0f);
float4 DLColor = float4(1.0f, 0.0f, 0.0f, 1.0f);  

An sich ist hier alles eher konventionell: Die WorldInverseTranspose-Matrix ist vom bekannten Datentyp, während die Einfallsrichtung des Lichts als float3 (also als dreidimensionaler Vektor) festgelegt ist. Die Farbe sitzt wie immer in einem vierdimensionalen Vektor. Beiden Vektoren werden Default-Werte zugewiesen. Beim Berechnen der Lichtintensität benötigt man neben den Positionsdaten des Vertex‘ zusätzlich auch den Normalvektor. Da er von der Grafikkarte sowieso berechnet wird, kann man ihn über ein Semantic in der struct VertexShaderInput anfordern:

struct VertexShaderInput
{
  float4 Position : POSITION0;
  float4 NormalVector : NORMAL0;
};  

Damit ist man auch schon bei der eigentlichen Berechnung angekommen. Sie erfolgt im Vertex-Shader (Listing 7).

Listing 7
VertexShaderOutput VertexShaderFunction(VertexShaderInput input)
{
  VertexShaderOutput output;

  float4 worldPosition = mul(input.Position, World);
  float4 viewPosition = mul(worldPosition, View);
  output.Position = mul(viewPosition, Projection);

  // TODO: add your vertex shader code here.
  float4 normal = normalize(mul(input.NormalVector, WorldInverseTranspose));
  float lightI = dot(normal,DLVector);
  output.DiffuseColor = saturate(DLColor * lightI);

  return output;
}  

Zuerst errechnet man, wie weiter oben gezeigt, die Position, danach den Farbwert. Da die Berechnung zwar mathematisch anspruchsvoll ist, aber nicht unbedingt verstanden werden muss, soll sie im nächsten Abschnitt näher behandelt werden. Zur Ausgabe der errechneten Farbwerte an den Pixel-Shader muss man VertexShaderOutput um eine weitere Variable erweitern:

struct VertexShaderOutput
{
  float4 Position : POSITION0;
  float4 DiffuseColor : COLOR0;
};  

Im Pixel-Shader gibt man die errechneten Daten aus:

float4 PixelShaderFunction(VertexShaderOutput input) : COLOR0
{
  return input.DiffuseColor;
}  

Da man in HLSL Matrizen nicht ohne Weiteres invertieren kann, wird das in C# erledigt. Theoretisch könnte man das natürlich auch von Hand in HLSL realisieren, was aber in diesem Beispiel keinen Sinn machen würde (Listing 8).

Listing 8
foreach (ModelMeshPart mp in m.MeshParts)
{
  mp.Effect = Game1.myFirstEffect;
  mp.Effect.Parameters["World"].SetValue(localWorld);
  mp.Effect.Parameters["View"].SetValue(_view);
  mp.Effect.Parameters["Projection"].SetValue(_pro);
  Matrix worldInverseTranspose = Matrix.Transpose(Matrix.Invert(localWorld));
  mp.Effect.Parameters["WorldInverseTranspose"].SetValue(worldInverseTranspose);

}  

Ist man damit fertig, kann die Anwendung ausgeführt werden. Das Resultat zeigt Abbildung 5.

Abb. 5: Fortan mit Objektgeometrie
Abb. 5: Fortan mit Objektgeometrie
Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -