Erstellen eines Neon-Vektor-Shooters in XNA: Bloom und Schwarze Löcher
German (Deutsch) translation by Katharina Grigorovich-Nevolina (you can also view the original English article)
In dieser Reihe von Tutorials zeige ich Ihnen, wie Sie in XNA einen Neon-Twin-Stick-Shooter wie Geometry Wars erstellen. Das Ziel dieser Tutorials ist es nicht, Ihnen eine exakte Nachbildung von Geometry Wars zu hinterlassen, sondern die erforderlichen Elemente zu erläutern, mit denen Sie Ihre eigene hochwertige Variante erstellen können.
Überblick
In der bisherigen Serie haben wir das grundlegende Gameplay für unseren Neon-Twin-Stick-Shooter Shape Blaster eingerichtet. In diesem Tutorial erstellen wir den charakteristischen Neon-Look, indem wir einen Bloom-Nachbearbeitungsfilter hinzufügen.
Einfache Effekte wie dieser oder Partikeleffekte können ein Spiel erheblich attraktiver machen, ohne dass Änderungen am Gameplay erforderlich sind. Die effektive Nutzung visueller Effekte ist ein wichtiger Aspekt in jedem Spiel. Nach dem Hinzufügen des Bloom-Filters fügen wir dem Spiel auch schwarze Löcher hinzu.
Bloom-Nachbearbeitungseffekt
Bloom beschreibt den Effekt, den Sie sehen, wenn Sie ein Objekt mit einem hellen Licht dahinter betrachten und das Licht über das Objekt zu bluten scheint. In Shape Blaster lässt der Bloom-Effekt die hellen Linien der Schiffe und Partikel wie helle, leuchtende Neonlichter aussehen.



Um Bloom in unserem Spiel anzuwenden, müssen wir unsere Szene auf ein Renderziel rendern und dann unseren Bloom-Filter auf dieses Renderziel anwenden.
Bloom arbeitet in drei Schritten:
- Extrahieren Sie die hellen Teile des Bildes.
- Verwischen Sie die hellen Teile.
- Kombinieren Sie das unscharfe Bild mit dem Originalbild, während Sie einige Helligkeits- und Sättigungsanpassungen vornehmen.
Für jeden dieser Schritte ist ein Shader erforderlich - im Wesentlichen ein kurzes Programm, das auf Ihrer Grafikkarte ausgeführt wird. Shader in XNA sind in einer speziellen Sprache namens High-Level Shader Language(HLSL) geschrieben. Die folgenden Beispielbilder zeigen das Ergebnis jedes Schritts.












Hinzufügen von Bloom zu Shape Blaster
Für unseren Bloom-Filter verwenden wir das XNA Bloom Postprocess Sample.
Die Integration des Blütenmusters in unser Projekt ist einfach. Suchen Sie zunächst die beiden Codedateien aus dem Beispiel BloomComponent.cs
und BloomSettings.cs
und fügen Sie sie dem ShapeBlaster-Projekt hinzu. Fügen Sie außerdem BloomCombine.fx
, BloomExtract.fx
und GaussianBlur.fx
zum Content-Pipeline-Projekt hinzu.
Fügen Sie in GameRoot
eine using
-Anweisung für den BloomPostprocess
-Namespace hinzu und fügen Sie eine BloomComponent
-Mitgliedsvariable hinzu.
BloomComponent bloom;
Fügen Sie im GameRoot
-Konstruktor die folgenden Zeilen hinzu.
bloom = new BloomComponent(this); Components.Add(bloom); bloom.Settings = new BloomSettings(null, 0.25f, 4, 2, 1, 1.5f, 1);
Fügen Sie zum Schluss ganz am Anfang von GameRoot.Draw()
die folgende Zeile hinzu.
bloom.BeginDraw();
Das ist es. Wenn Sie das Spiel jetzt ausführen, sollten Sie die Blüte in Wirkung sehen.
Wenn Sie bloom.BeginDraw()
aufrufen, werden nachfolgende Zeichenaufrufe an ein Renderziel umgeleitet, auf das bloom angewendet wird. Wenn Sie base.Draw()
am Ende der GameRoot.Draw()
-Methode aufrufen, wird die Draw()
-Methode der BloomComponent
aufgerufen. Hier wird die Blüte angewendet und die Szene in den hinteren Puffer gezeichnet. Daher muss zwischen den Aufrufen von bloom.BeginDraw()
und base.Draw()
alles gezeichnet werden, was bloom angewendet werden muss.
Tipp: Wenn Sie etwas ohne Blüte zeichnen möchten (z. B. die Benutzeroberfläche), zeichnen Sie es nach dem Aufruf von base.Draw()
.
Sie können die Bloom-Einstellungen nach Ihren Wünschen anpassen. Ich habe folgende Werte gewählt:
-
0.25
für die Bloom-Schwelle. Dies bedeutet, dass Teile des Bildes, die weniger als ein Viertel der vollen Helligkeit aufweisen, nicht zur Blüte beitragen. -
4
für die Unschärfemenge. Für die mathematisch geneigten Personen ist dies die Standardabweichung der Gaußschen Unschärfe. Größere Werte verwischen die Lichtblüte mehr. Beachten Sie jedoch, dass der Unschärfeshader unabhängig von der Unschärfemenge so eingestellt ist, dass eine feste Anzahl von Samples verwendet wird. Wenn Sie diesen Wert zu hoch einstellen, wird die Unschärfe über den Radius hinausgehen, aus dem die Shader-Samples stammen, und Artefakte werden angezeigt. Idealerweise sollte dieser Wert nicht mehr als ein Drittel Ihres Abtastradius betragen, um sicherzustellen, dass der Fehler vernachlässigbar ist. -
2
für die Bloom-Intensität, die bestimmt, wie stark die Blüte das Endergebnis beeinflusst. -
1
für die Basisintensität, die bestimmt, wie stark das Originalbild das Endergebnis beeinflusst. -
1.5
für die Blütensättigung. Dies führt dazu, dass das Leuchten um helle Objekte gesättigtere Farben aufweist als die Objekte selbst. Ein hoher Wert wurde gewählt, um das Aussehen von Neonlichtern zu simulieren. Wenn Sie in die Mitte eines hellen Neonlichts schauen, sieht es fast weiß aus, während das Leuchten um es herum stärker gefärbt ist. -
1
für die Basensättigung. Dieser Wert beeinflusst die Sättigung des Basisbildes.






Bloom unter der Haube
Der Bloom-Filter ist in der BloomComponent
-Klasse implementiert. Die Bloom-Komponente erstellt und lädt zunächst die erforderlichen Ressourcen in ihrer LoadContent()
-Methode. Hier werden die drei erforderlichen Shader geladen und drei Renderziele erstellt.
Das erste Renderziel, sceneRenderTarget
, dient zum Halten der Szene, auf die die Blüte angewendet wird. Die anderen beiden, renderTarget1
und renderTarget2
, werden verwendet, um die Zwischenergebnisse zwischen den einzelnen Rendering-Durchläufen vorübergehend zu speichern. Diese Renderziele werden zur Hälfte der Spielauflösung erstellt, um die Leistungskosten zu senken. Dies verringert nicht die endgültige Qualität der Blüte, da wir die Blütenbilder ohnehin unscharf machen.
Bloom erfordert vier Rendering-Durchgänge, wie in diesem Diagramm gezeigt:



In XNA kapselt die Effect
-Klasse einen Shader. Sie schreiben den Code für den Shader in eine separate Datei, die Sie der Inhaltspipeline hinzufügen. Dies sind die Dateien mit der Erweiterung .fx
, die wir zuvor hinzugefügt haben. Sie laden den Shader in ein Effekt
-Objekt, indem Sie die Content.Load <Effect>()
-Methode in LoadContent()
aufrufen. Der einfachste Weg, einen Shader in einem 2D-Spiel zu verwenden, besteht darin, das Effekt
-Objekt als Parameter an SpriteBatch.Begin()
zu übergeben.
Es gibt verschiedene Arten von Shadern, aber für den Bloom-Filter werden nur Pixel-Shader (manchmal auch Fragment-Shader genannt) verwendet. Ein Pixel-Shader ist ein kleines Programm, das für jedes gezeichnete Pixel einmal ausgeführt wird und die Farbe des Pixels bestimmt. Wir werden jeden der verwendeten Shader durchgehen.
Der BloomExtract
Shader
Der BloomExtract
-Shader ist der einfachste der drei Shader. Seine Aufgabe besteht darin, die Bereiche des Bildes zu extrahieren, die heller als ein bestimmter Schwellenwert sind, und dann die Farbwerte neu zu skalieren, um den gesamten Farbbereich zu nutzen. Alle Werte unterhalb des Schwellenwerts werden schwarz.
Der vollständige Shader-Code wird unten angezeigt.
sampler TextureSampler : register(s0); float BloomThreshold; float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { // Look up the original image color. float4 c = tex2D(TextureSampler, texCoord); // Adjust it to keep only values brighter than the specified threshold. return saturate((c - BloomThreshold) / (1 - BloomThreshold)); } technique BloomExtract { pass Pass1 { PixelShader = compile ps_2_0 PixelShaderFunction(); } }
Machen Sie sich keine Sorgen, wenn Sie mit HLSL nicht vertraut sind. Lassen Sie uns untersuchen, wie dies funktioniert.
sampler TextureSampler : register(s0);
In diesem ersten Teil wird ein Textur-Sampler namens TextureSampler
deklariert. SpriteBatch
bindet eine Textur an diesen Sampler, wenn er mit diesem Shader zeichnet. Die Angabe, an welches Register gebunden werden soll, ist optional. Wir verwenden den Sampler, um Pixel aus der gebundenen Textur nachzuschlagen.
float BloomThreshold;
BloomThreshold
ist ein Parameter, den wir aus unserem C#-Code festlegen können.
float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 {
Dies ist unsere Pixel-Shader-Funktionsdeklaration, die Texturkoordinaten als Eingabe verwendet und eine Farbe zurückgibt. Die Farbe wird als float4
zurückgegeben. Dies ist eine Sammlung von vier Floats, ähnlich wie bei einem Vector4
in XNA. Sie speichern die Rot-, Grün-, Blau- und Alphakomponenten der Farbe als Werte zwischen Null und Eins.
TEXCOORD0
und COLOR0
werden als Semantik bezeichnet und geben dem Compiler an, wie der texCoord
-Parameter und der Rückgabewert verwendet werden. Für jede Pixelausgabe enthält texCoord
die Koordinaten des entsprechenden Punkts in der Eingabetextur, wobei (0, 0)
die obere linke Ecke und (1, 1)
die untere rechte Ecke ist.
// Look up the original image color. float4 c = tex2D(TextureSampler, texCoord); // Adjust it to keep only values brighter than the specified threshold. return saturate((c - BloomThreshold) / (1 - BloomThreshold));
Hier wird die ganze eigentliche Arbeit erledigt. Es ruft die Pixelfarbe von der Textur ab, subtrahiert BloomThreshold
von jeder Farbkomponente und skaliert sie dann wieder hoch, sodass der Maximalwert eins ist. Die Funktion saturate()
klemmt dann die Farbkomponenten zwischen Null und Eins.
Möglicherweise stellen Sie fest, dass c
und BloomThreshold
nicht vom gleichen Typ sind, da c
ein float4
und BloomThreshold
ein float
ist. Mit HLSL können Sie Operationen mit diesen verschiedenen Typen ausführen, indem Sie den float
im Wesentlichen in einen float4
verwandeln, bei dem alle Komponenten gleich sind. (c - BloomThreshold)
wird effektiv:
c -- float4(BloomThreshold, BloomThreshold, BloomThreshold, BloomThreshold)
Der Rest des Shaders erstellt einfach eine Technik, die die Pixel-Shader-Funktion verwendet, die für das Shader-Modell 2.0 kompiliert wurde.
Der GaussianBlur
Shader
Eine Gaußscher Weichzeichner verwischt ein Bild mit einer Gaußschen Funktion. Für jedes Pixel im Ausgabebild addieren wir die Pixel im Eingabebild, gewichtet mit ihrem Abstand vom Zielpixel. Nahe gelegene Pixel tragen stark zur endgültigen Farbe bei, während entfernte Pixel sehr wenig beitragen.
Da entfernte Pixel vernachlässigbare Beiträge leisten und die Suche nach Texturen kostspielig ist, werden Pixel nur innerhalb eines kurzen Radius abgetastet, anstatt die gesamte Textur abzutasten. Dieser Shader tastet Punkte innerhalb von 14 Pixeln des aktuellen Pixels ab.
Bei einer naiven Implementierung werden möglicherweise alle Punkte in einem Quadrat um das aktuelle Pixel abgetastet. Dies kann jedoch kostspielig sein. In unserem Beispiel müssten wir Punkte innerhalb eines 29 x 29-Quadrats abtasten (14 Punkte auf beiden Seiten des mittleren Pixels plus des mittleren Pixels). Das sind insgesamt 841 Samples für jedes Pixel in unserem Bild. Zum Glück gibt es eine schnellere Methode. Es stellt sich heraus, dass eine 2D-Gaußsche Unschärfe gleichbedeutend damit ist, das Bild zuerst horizontal und dann wieder vertikal zu verwischen. Für jede dieser eindimensionalen Unschärfen sind nur 29 Abtastwerte erforderlich, wodurch sich unsere Gesamtzahl auf 58 Abtastwerte pro Pixel verringert.
Ein weiterer Trick wird verwendet, um die Effizienz der Unschärfe weiter zu erhöhen. Wenn Sie die GPU anweisen, zwischen zwei Pixeln abzutasten, wird eine Mischung der beiden Pixel ohne zusätzliche Leistungskosten zurückgegeben. Da unsere Unschärfe ohnehin Pixel miteinander vermischt, können wir zwei Pixel gleichzeitig abtasten. Dies halbiert die Anzahl der benötigten Proben fast.
Unten finden Sie die relevanten Teile des GaussianBlur
-Shaders.
sampler TextureSampler : register(s0); #define SAMPLE_COUNT 15 float2 SampleOffsets[SAMPLE_COUNT]; float SampleWeights[SAMPLE_COUNT]; float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { float4 c = 0; // Combine a number of weighted image filter taps. for (int i = 0; i < SAMPLE_COUNT; i++) { c += tex2D(TextureSampler, texCoord + SampleOffsets[i]) * SampleWeights[i]; } return c; }
Der Shader ist eigentlich ganz einfach; Es werden nur ein Array von Offsets und ein entsprechendes Array von Gewichten benötigt und die gewichtete Summe berechnet. Die gesamte komplexe Mathematik befindet sich tatsächlich im C#-Code, der die Offset- und Weight-Arrays auffüllt. Dies erfolgt in den Methoden SetBlurEffectParameters()
und ComputeGaussian()
der BloomComponent
-Klasse. Bei der Durchführung des horizontalen Weiczeichner-Durchlaufs werden SampleOffsets
nur mit horizontalen Offsets gefüllt (die y-Komponenten sind alle Null), und natürlich gilt das Gegenteil für den vertikalen Durchlauf.
Der BloomCombine
Shader
Der BloomCombine
-Shader erledigt einige Dinge gleichzeitig. Es kombiniert die Blütentextur mit der ursprünglichen Textur und passt gleichzeitig die Intensität und Sättigung jeder Textur an.
Der Shader deklariert zunächst zwei Textur-Sampler und vier Float-Parameter.
sampler BloomSampler : register(s0); sampler BaseSampler : register(s1); float BloomIntensity; float BaseIntensity; float BloomSaturation; float BaseSaturation;
Zu beachten ist, dass SpriteBatch
die Textur, die Sie beim Aufrufen von SpriteBatch.Draw()
übergeben, automatisch an den ersten Sampler bindet, jedoch nichts automatisch an den zweiten Sampler bindet. Der zweite Sampler wird manuell in BloomComponent.Draw()
mit der folgenden Zeile festgelegt.
GraphicsDevice.Textures[1] = sceneRenderTarget;
Als nächstes haben wir eine Hilfsfunktion, die die Sättigung einer Farbe anpasst.
float4 AdjustSaturation(float4 color, float saturation) { // The constants 0.3, 0.59, and 0.11 are chosen because the // human eye is more sensitive to green light, and less to blue. float grey = dot(color, float3(0.3, 0.59, 0.11)); return lerp(grey, color, saturation); }
Diese Funktion nimmt eine Farbe und einen Sättigungswert an und gibt eine neue Farbe zurück. Bei einer Sättigung von 1
bleibt die Farbe unverändert. Wenn Sie 0
übergeben, wird Grau zurückgegeben, und wenn Sie Werte größer als 1 übergeben, wird eine Farbe mit erhöhter Sättigung zurückgegeben. Das Übergeben negativer Werte liegt tatsächlich außerhalb des Verwendungszwecks, invertiert jedoch die Farbe, wenn Sie dies tun.
Die Funktion ermittelt zunächst die Leuchtkraft der Farbe, indem eine gewichtete Summe verwendet wird, die auf der Empfindlichkeit unserer Augen gegenüber rotem, grünem und blauem Licht basiert. Es interpoliert dann linear zwischen Grau und der Originalfarbe um den angegebenen Sättigungsgrad. Diese Funktion wird von der Pixel-Shader-Funktion aufgerufen.
float4 PixelShaderFunction(float2 texCoord : TEXCOORD0) : COLOR0 { // Look up the bloom and original base image colors. float4 bloom = tex2D(BloomSampler, texCoord); float4 base = tex2D(BaseSampler, texCoord); // Adjust color saturation and intensity. bloom = AdjustSaturation(bloom, BloomSaturation) * BloomIntensity; base = AdjustSaturation(base, BaseSaturation) * BaseIntensity; // Darken down the base image in areas where there is a lot of bloom, // to prevent things looking excessively burned-out. base *= (1 - saturate(bloom)); // Combine the two images. return base + bloom; }
Auch dieser Shader ist ziemlich einfach. Wenn Sie sich fragen, warum das Basisbild in Bereichen mit heller Blüte abgedunkelt werden muss, denken Sie daran, dass das Hinzufügen von zwei Farben die Helligkeit erhöht und alle Farbkomponenten, die einen Wert größer als eins (volle Helligkeit) ergeben, auf eins gekürzt werden . Da das Bloom-Bild dem Basisbild ähnlich ist, würde dies dazu führen, dass ein Großteil des Bildes mit einer Helligkeit von über 50% maximal wird. Durch Abdunkeln des Basisbilds werden alle Farben wieder in den Farbbereich zurückgeführt, den wir ordnungsgemäß anzeigen können.
Schwarze Löcher
Einer der interessantesten Feinde in Geometry Wars ist das Schwarze Loch. Lassen Sie uns untersuchen, wie wir in Shape Blaster etwas Ähnliches machen können. Wir werden jetzt die Grundfunktionalität erstellen und den Feind im nächsten Tutorial erneut besuchen, um Partikeleffekte und Partikelinteraktionen hinzuzufügen.

Grundfunktionalität
Die schwarzen Löcher ziehen das Schiff des Spielers, in der Nähe befindliche Feinde und (nach dem nächsten Tutorial) Partikel ein, stoßen aber Kugeln ab.
Es gibt viele mögliche Funktionen, die wir zur Anziehung oder Abstoßung verwenden können. Am einfachsten ist es, eine konstante Kraft anzuwenden, damit das Schwarze Loch unabhängig von der Entfernung des Objekts mit der gleichen Stärke zieht. Eine andere Möglichkeit besteht darin, die Kraft linear von Null in einem maximalen Abstand auf die volle Stärke für Objekte direkt über dem Schwarzen Loch zu erhöhen.
Wenn wir die Schwerkraft realistischer modellieren möchten, können wir das umgekehrte Quadrat der Entfernung verwenden, was bedeutet, dass die Schwerkraft proportional zu \(1/ Entfernung ^2\) ist. Wir werden tatsächlich jede dieser drei Funktionen verwenden, um verschiedene Objekte zu handhaben. Die Kugeln werden mit einer konstanten Kraft abgestoßen, die Feinde und das Schiff des Spielers werden mit einer linearen Kraft angezogen und die Partikel verwenden eine umgekehrte quadratische Funktion.
Wir werden eine neue Klasse für Schwarze Löcher machen. Beginnen wir mit der Grundfunktionalität.
class BlackHole : Entity { private static Random rand = new Random(); private int hitpoints = 10; public BlackHole(Vector2 position) { image = Art.BlackHole; Position = position; Radius = image.Width / 2f; } public void WasShot() { hitpoints--; if (hitpoints <= 0) IsExpired = true; } public void Kill() { hitpoints = 0; WasShot(); } public override void Draw(SpriteBatch spriteBatch) { // make the size of the black hole pulsate float scale = 1 + 0.1f * (float)Math.Sin(10 * GameRoot.GameTime.TotalGameTime.TotalSeconds); spriteBatch.Draw(image, Position, null, color, Orientation, Size / 2f, scale, 0, 0); } }
Die schwarzen Löcher brauchen zehn Schüsse, um zu töten. Wir passen die Skalierung des Sprites leicht an, damit es pulsiert. Wenn Sie entscheiden, dass die Zerstörung von Schwarzen Löchern auch Punkte bringen soll, müssen Sie ähnliche Anpassungen an der BlackHole
-Klasse vornehmen wie an der feindlichen Klasse.
Als nächstes lassen wir die Schwarzen Löcher tatsächlich eine Kraft auf andere Entitäten ausüben. Wir benötigen eine kleine Hilfsmethode von unserem EntityManager
.
public static IEnumerable GetNearbyEntities(Vector2 position, float radius) { return entities.Where(x => Vector2.DistanceSquared(position, x.Position) < radius * radius); }
Diese Methode könnte durch Verwendung eines komplizierteren räumlichen Partitionierungsschemas effizienter gestaltet werden, aber für die Anzahl der Entitäten, die wir haben werden, ist es in Ordnung, wie es ist. Jetzt können wir die Schwarzen Löcher dazu bringen, in ihrer Update()
-Methode Kraft anzuwenden.
public override void Update() { var entities = EntityManager.GetNearbyEntities(Position, 250); foreach (var entity in entities) { if (entity is Enemy && !(entity as Enemy).IsActive) continue; // bullets are repelled by black holes and everything else is attracted if (entity is Bullet) entity.Velocity += (entity.Position - Position).ScaleTo(0.3f); else { var dPos = Position - entity.Position; var length = dPos.Length(); entity.Velocity += dPos.ScaleTo(MathHelper.Lerp(2, 0, length / 250f)); } } }
Schwarze Löcher wirken sich nur auf Objekte innerhalb eines ausgewählten Radius (250 Pixel) aus. Auf Kugeln innerhalb dieses Radius wird eine konstante Abstoßungskraft ausgeübt, während auf alles andere eine lineare Anziehungskraft ausgeübt wird.
Wir müssen dem EntityManager
die Kollisionsbehandlung für Schwarze Löcher hinzufügen. Fügen Sie eine List<>
für Schwarze Löcher hinzu, wie wir es für die anderen Entitätstypen getan haben, und fügen Sie den folgenden Code in EntityManager.HandleCollisions()
hinzu.
// handle collisions with black holes for (int i = 0; i < blackHoles.Count; i++) { for (int j = 0; j < enemies.Count; j++) if (enemies[j].IsActive && IsColliding(blackHoles[i], enemies[j])) enemies[j].WasShot(); for (int j = 0; j < bullets.Count; j++) { if (IsColliding(blackHoles[i], bullets[j])) { bullets[j].IsExpired = true; blackHoles[i].WasShot(); } } if (IsColliding(PlayerShip.Instance, blackHoles[i])) { KillPlayer(); break; } }
Öffnen Sie abschließend die EnemySpawner
-Klasse und lassen Sie sie einige schwarze Löcher erstellen. Ich habe die maximale Anzahl von Schwarzen Löchern auf zwei begrenzt und eine 1:600-Chance gegeben, dass ein Schwarzes Loch jeden Frame hervorbringt.
if (EntityManager.BlackHoleCount < 2 && rand.Next((int)inverseBlackHoleChance) == 0) EntityManager.Add(new BlackHole(GetSpawnPosition()));
Schlussfolgerung
Wir haben Bloom mit verschiedenen Shadern und schwarzen Löchern mit verschiedenen Kraftformeln hinzugefügt. Shape Blaster fängt an, ziemlich gut auszusehen. Im nächsten Teil werden wir einige verrückte, übertriebene Partikeleffekte hinzufügen.