Fai uno sparatutto al neon in XNA Bloom e Black Holes

In questa serie di tutorial, ti mostrerò come realizzare uno sparatutto al neon con due gemelli, come Geometry Wars, in XNA. L'obiettivo di queste esercitazioni non è quello di lasciarti con una replica esatta di Geometry Wars, ma piuttosto di esaminare gli elementi necessari che ti permetteranno di creare la tua variante di alta qualità.


Panoramica

Nella serie finora, abbiamo impostato il gameplay di base per il nostro sparatutto al neon gemello, Shape Blaster. In questo tutorial creeremo il look neon alla moda aggiungendo un filtro di post-elaborazione della fioritura.

Avvertenza: forte!

Effetti semplici come questo o effetti particellari possono rendere il gioco molto più attraente senza richiedere modifiche al gameplay. L'uso efficace degli effetti visivi è una considerazione importante in ogni gioco. Dopo aver aggiunto il filtro bloom, aggiungeremo anche buchi neri al gioco.


Effetto post-elaborazione della fioritura

Bloom descrive l'effetto che vedi quando guardi un oggetto con una luce brillante dietro di esso e la luce sembra sanguinare sopra l'oggetto. In Shape Blaster, l'effetto bloom renderà le linee luminose delle navi e delle particelle simili a luci al neon brillanti e luminose.

Luce solare che sboccia attraverso gli alberi

Per applicare la fioritura nel nostro gioco, dobbiamo rendere la nostra scena a un bersaglio di rendering e quindi applicare il nostro filtro di fioritura a quell'obiettivo di rendering.

Bloom funziona in tre passaggi:

  1. Estrai le parti luminose dell'immagine.
  2. Sfocare le parti luminose.
  3. Ricombina l'immagine sfocata con l'immagine originale mentre esegui alcune regolazioni di luminosità e saturazione.

Ognuno di questi passaggi richiede a Shader - essenzialmente un breve programma che gira sulla tua scheda grafica. Gli shader in XNA sono scritti in un linguaggio speciale chiamato High-Level Shader Language (HLSL). Le immagini di esempio qui sotto mostrano il risultato di ogni passaggio.

Immagine iniziale Le aree luminose estratte dall'immagine Le aree luminose dopo la sfocatura Il risultato finale dopo la ricombinazione con l'immagine originale

Aggiungere Bloom to Shape Blaster

Per il nostro filtro di fioritura, utilizzeremo il campione postelaborazione XNA Bloom.

Integrare il campione di fioritura con il nostro progetto è facile. In primo luogo, individuare i due file di codice dall'esempio, BloomComponent.cs e BloomSettings.cs, e aggiungili al ShapeBlaster progetto. Aggiungi anche BloomCombine.fx, BloomExtract.fx, e GaussianBlur.fx al progetto della pipeline di contenuti.

Nel GameRoot, aggiungere un utilizzando dichiarazione per il BloomPostprocess spazio dei nomi e aggiungere a BloomComponent variabile membro.

 BloomComponent bloom;

Nel GameRoot costruttore, aggiungere le seguenti linee.

 bloom = new BloomComponent (this); Components.Add (bloom); bloom.Settings = new BloomSettings (null, 0.25f, 4, 2, 1, 1.5f, 1);

Finalmente, all'inizio di GameRoot.Draw (), aggiungi la seguente riga.

 bloom.BeginDraw ();

Questo è tutto. Se esegui il gioco ora, dovresti vedere la fioritura in effetto.

Quando chiami bloom.BeginDraw (), reindirizza le chiamate di estrazione successive a un obiettivo di rendering a cui verrà applicata la fioritura. Quando chiami base.Draw () alla fine di GameRoot.Draw () metodo, il BloomComponent'S Disegnare() il metodo è chiamato. Qui è dove viene applicata la fioritura e la scena viene disegnata sul buffer posteriore. Pertanto, deve essere tracciato tutto ciò che deve essere applicato per fiorire tra le chiamate a bloom.BeginDraw () e base.Draw ().

Mancia: Se vuoi disegnare qualcosa senza fiorire (ad esempio, l'interfaccia utente), disegnalo dopo la chiamata a base.Draw ().

Puoi modificare le impostazioni di fioritura a tuo piacimento. Ho scelto i seguenti valori:

  • 0.25 per la soglia della fioritura. Ciò significa che qualsiasi parte dell'immagine con meno di un quarto di luminosità totale non contribuirà alla fioritura.
  • 4 per la quantità di sfocatura. Per i matematici inclinati, questa è la deviazione standard della sfocatura gaussiana. Valori più grandi sfoceranno maggiormente la luce. Tuttavia, tieni presente che lo shader di sfocatura è impostato per utilizzare un numero fisso di campioni, indipendentemente dalla quantità di sfocatura. Se si imposta questo valore troppo alto, la sfocatura si estenderà oltre il raggio da cui provengono gli shader e appariranno artefatti. Idealmente, questo valore dovrebbe essere non più di un terzo del raggio di campionamento per garantire che l'errore sia trascurabile.
  • 2 per l'intensità della fioritura, che determina quanto fortemente la fioritura influenzi il risultato finale.
  • 1 per l'intensità di base, che determina quanto fortemente l'immagine originale influisce sul risultato finale.
  • 1.5 per la saturazione della fioritura. Ciò fa sì che il bagliore intorno agli oggetti luminosi abbia colori più saturi rispetto agli oggetti stessi. È stato scelto un valore elevato per simulare l'aspetto delle luci al neon. Se osservi il centro di una luce al neon luminosa, sembra quasi bianco, mentre il bagliore intorno a esso è più fortemente colorato.
  • 1 per la saturazione di base. Questo valore influisce sulla saturazione dell'immagine di base.
Senza fioritura Con la fioritura

Bloom Under the Hood

Il filtro bloom è implementato nel BloomComponent classe. Il componente bloom inizia creando e caricando le risorse necessarie nel suo LoadContent () metodo. Qui carica i tre shader richiesti e crea tre obiettivi di rendering.

Il primo obiettivo di rendering, sceneRenderTarget, è per contenere la scena alla quale verrà applicata la fioritura. Gli altri due, renderTarget1 e renderTarget2, sono utilizzati per mantenere temporaneamente i risultati intermedi tra ogni passaggio di rendering. Questi obiettivi di rendering sono fatti a metà della risoluzione del gioco per ridurre il costo delle prestazioni. Questo non riduce la qualità finale della fioritura, perché in ogni caso saremo offuscando le immagini di fioritura.

Bloom richiede quattro passaggi di rendering, come mostrato in questo diagramma:

In XNA, il Effetto la classe incapsula uno shader. Scrivi il codice per lo shader in un file separato, che aggiungi alla pipeline del contenuto. Questi sono i file con .fx estensione che abbiamo aggiunto in precedenza. Carichi lo shader in un Effetto oggetto chiamando il Content.Load() metodo in LoadContent (). Il modo più semplice per usare uno shader in un gioco 2D è passare il Effetto oggetto come parametro a SpriteBatch.Begin ().

Esistono diversi tipi di shader, ma per il filtro bloom verrà utilizzato solo pixel shader (a volte chiamato frammenti di shader). Un pixel shader è un piccolo programma che viene eseguito una volta per ogni pixel che si disegna e determina il colore del pixel. Andremo su ciascuno degli shader usati.

Il BloomExtract Shader

Il BloomExtract shader è il più semplice dei tre shader. Il suo compito è estrarre le aree dell'immagine che sono più luminose di alcune soglie e quindi ridimensionare i valori del colore per utilizzare l'intera gamma di colori. Qualsiasi valore al di sotto della soglia diventerà nero.

Il codice completo dello shader è mostrato sotto.

 sampler TextureSampler: register (s0); float BloomThreshold; float4 PixelShaderFunction (float2 texCoord: TEXCOORD0): COLOR0 // Cerca il colore dell'immagine originale. float4 c = tex2D (TextureSampler, texCoord); // Regola per mantenere solo i valori più luminosi della soglia specificata. return satate ((c - BloomThreshold) / (1 - BloomThreshold));  tecnica BloomExtract pass Pass1 PixelShader = compila ps_2_0 PixelShaderFunction (); 

Non preoccuparti se non hai familiarità con HLSL. Esaminiamo come funziona.

 sampler TextureSampler: register (s0);

Questa prima parte dichiara un campionatore di texture chiamato TextureSampler. SpriteBatch legherà una trama a questo campionatore quando si disegna con questo shader. Specificare quale registro associare a è facoltativo. Usiamo il campionatore per cercare i pixel dalla texture rilegata.

 float BloomThreshold;

BloomThreshold è un parametro che possiamo impostare dal nostro codice C #.

 float4 PixelShaderFunction (float2 texCoord: TEXCOORD0): COLOR0 

Questa è la nostra dichiarazione di funzione shader pixel che prende le coordinate texture come input e restituisce un colore. Il colore viene restituito come a float4. Questa è una collezione di quattro float, molto simile a a vector4 in XNA. Memorizzano i componenti rosso, verde, blu e alfa del colore come valori compresi tra zero e uno.

TEXCOORD0 e COLOR0 sono chiamati semantica, e indicano al compilatore come il texCoord parametro e il valore di ritorno sono usati. Per ogni uscita di pixel, texCoord conterrà le coordinate del punto corrispondente nella trama di input, con (0, 0) essendo l'angolo in alto a sinistra e (1, 1) essendo in basso a destra.

 // Cerca il colore dell'immagine originale. float4 c = tex2D (TextureSampler, texCoord); // Regola per mantenere solo i valori più luminosi della soglia specificata. return satate ((c - BloomThreshold) / (1 - BloomThreshold));

Questo è dove tutto il vero lavoro è fatto. Prende il colore dei pixel dalla texture, sottrae BloomThreshold da ciascun componente di colore, quindi ridimensiona il backup in modo che il valore massimo sia uno. Il saturare() funzione quindi blocca i componenti del colore tra zero e uno.

Potresti accorgertene c e BloomThreshold non sono dello stesso tipo, come c è un float4 e BloomThreshold è un galleggiante. HLSL ti permette di fare operazioni con questi diversi tipi essenzialmente girando il galleggiante in un float4 con tutti i componenti uguali. (c - BloomThreshold) diventa efficacemente:

 c - float4 (BloomThreshold, BloomThreshold, BloomThreshold, BloomThreshold)

Il resto dello shader crea semplicemente una tecnica che utilizza la funzione pixel shader, compilata per il modello shader 2.0.

Il Sfocatura gaussiana Shader

Una sfocatura gaussiana sfoca un'immagine utilizzando una funzione gaussiana. Per ogni pixel nell'immagine di output, sommiamo i pixel nell'immagine di input ponderati in base alla loro distanza dal pixel di destinazione. I pixel vicini contribuiscono notevolmente al colore finale, mentre i pixel distanti contribuiscono molto poco.

Poiché i pixel distanti apportano contributi trascurabili e poiché le ricerche di texture sono costose, campioniamo solo i pixel entro un raggio breve anziché campionare l'intera trama. Questo shader campionerà punti entro 14 pixel del pixel corrente.

Un'implementazione ingenua potrebbe campionare tutti i punti in un quadrato attorno al pixel corrente. Tuttavia, questo può essere costoso. Nel nostro esempio, dovremmo campionare punti all'interno di un quadrato 29x29 (14 punti su entrambi i lati del pixel centrale, più il pixel centrale). Questo è un totale di 841 campioni per ogni pixel nella nostra immagine. Fortunatamente, c'è un metodo più veloce. Si scopre che eseguire una sfocatura gaussiana 2D equivale a sfocare dapprima l'immagine in orizzontale e quindi a sfocarla nuovamente verticalmente. Ognuna di queste sfocature unidimensionali richiede solo 29 campioni, riducendo il nostro totale a 58 campioni per pixel.

Un altro trucco viene utilizzato per aumentare ulteriormente l'efficienza della sfocatura. Quando si dice alla GPU di campionare tra due pixel, restituirà una miscela dei due pixel senza costi aggiuntivi di prestazioni. Poiché la nostra sfocatura sta comunque combinando i pixel insieme, ciò ci consente di campionare due pixel alla volta. Questo taglia il numero di campioni richiesti quasi a metà.

Di seguito sono riportate le parti pertinenti del Sfocatura gaussiana Shader.

 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; // Combina un numero di tocchi di filtro delle immagini ponderati. per (int i = 0; i < SAMPLE_COUNT; i++)  c += tex2D(TextureSampler, texCoord + SampleOffsets[i]) * SampleWeights[i];  return c; 

Lo shader è in realtà abbastanza semplice; prende solo una serie di offset e una matrice corrispondente di pesi e calcola la somma ponderata. Tutta la matematica complessa è in realtà nel codice C # che popola gli array di offset e di peso. Questo è fatto nel SetBlurEffectParameters () e ComputeGaussian () metodi del BloomComponent classe. Quando si esegue il passaggio sfocato orizzontale, SampleOffsets sarà popolato con solo offset orizzontali (i componenti y sono tutti zero), e ovviamente il contrario è vero per il passaggio verticale.

Il BloomCombine Shader

Il BloomCombine lo shader fa alcune cose contemporaneamente. Combina la texture del fiore con la texture originale e regola anche l'intensità e la saturazione di ogni texture.

Lo shader inizia dichiarando due sampler texture e quattro parametri float.

 sampler BloomSampler: register (s0); sampler BaseSampler: register (s1); float BloomIntensity; float BaseIntensity; float BloomSaturation; galleggiante BaseSaturazione;

Una cosa da notare è quella SpriteBatch legherà automaticamente la trama che hai passato durante la chiamata SpriteBatch.Draw () al primo campionatore, ma non legherà automaticamente nulla al secondo campionatore. Il secondo campionatore viene impostato manualmente BloomComponent.Draw () con la seguente riga.

 GraphicsDevice.Textures [1] = sceneRenderTarget;

Successivamente abbiamo una funzione di supporto che regola la saturazione di un colore.

 float4 AdjustSaturation (float4 color, float satation) // Le costanti 0.3, 0.59 e 0.11 sono scelte perché // l'occhio umano è più sensibile alla luce verde e meno al blu. float gray = dot (color, float3 (0.3, 0.59, 0.11)); return lerp (grigio, colore, saturazione); 

Questa funzione prende un colore e un valore di saturazione e restituisce un nuovo colore. Passando una saturazione di 1 lascia il colore invariato. passaggio 0 restituirà grigio e valori di passaggio maggiori di uno restituiranno un colore con maggiore saturazione. Il passaggio di valori negativi è davvero al di fuori dell'uso previsto, ma invertirà il colore se lo si fa.

La funzione funziona rilevando innanzitutto la luminosità del colore prendendo una somma ponderata in base alla sensibilità dei nostri occhi alla luce rossa, verde e blu. Quindi interpola linearmente tra il grigio e il colore originale per la quantità di saturazione specificata. Questa funzione è chiamata dalla funzione pixel shader.

 float4 PixelShaderFunction (float2 texCoord: TEXCOORD0): COLOR0 // Cerca la fioritura e i colori dell'immagine di base originale. float4 bloom = tex2D (BloomSampler, texCoord); float4 base = tex2D (BaseSampler, texCoord); // Regola la saturazione e l'intensità del colore. bloom = AdjustSaturation (bloom, BloomSaturation) * BloomIntensity; base = AdjustSaturation (base, BaseSaturation) * BaseIntensity; // Scurisci l'immagine di base nelle aree in cui c'è molta fioritura, // per evitare che le cose sembrino eccessivamente bruciate. base * = (1 - saturo (fiore)); // Combina le due immagini. ritorno base + fioritura; 

Di nuovo, questo shader è abbastanza semplice. Se ti stai chiedendo perché l'immagine di base debba essere oscurata in aree con una brillante fioritura, ricorda che l'aggiunta di due colori insieme aumenta la luminosità e qualsiasi componente di colore che si aggiunge a un valore maggiore di uno (piena luminosità) verrà ritagliato in uno . Dal momento che l'immagine di fioritura è simile all'immagine di base, ciò causerebbe il superamento di gran parte dell'immagine con una luminosità superiore al 50%. L'oscuramento dell'immagine di base riporta tutti i colori nella gamma di colori che possiamo visualizzare correttamente.


Buchi neri

Uno dei nemici più interessanti in Geometry Wars è il buco nero. Esaminiamo come possiamo creare qualcosa di simile in Shape Blaster. Creeremo ora le funzionalità di base e rivisiteremo il nemico nel prossimo tutorial per aggiungere effetti particellari e interazioni tra particelle.

Un buco nero con particelle orbitanti

Funzionalità di base

I buchi neri attireranno le particelle del giocatore, i nemici vicini e (dopo il prossimo tutorial), ma respingeranno i proiettili.

Ci sono molte funzioni possibili che possiamo usare per attrazione o repulsione. Il più semplice è usare la forza costante in modo che il buco nero tiri con la stessa forza indipendentemente dalla distanza dell'oggetto. Un'altra possibilità è di aumentare linearmente la forza da zero a una certa distanza massima, a piena forza per gli oggetti direttamente sopra il buco nero.

Se vogliamo modellare la gravità in modo più realistico, possiamo usare il quadrato inverso della distanza, il che significa che la forza di gravità è proporzionale a \ (1 / distanza ^ 2 \). In realtà utilizzeremo ognuna di queste tre funzioni per gestire oggetti diversi. I proiettili saranno respinti con una forza costante, i nemici e la nave del giocatore saranno attratti con una forza lineare, e le particelle useranno una funzione inversa quadrata.

Faremo una nuova classe per i buchi neri. Iniziamo con le funzionalità di base.

 classe BlackHole: Entity private static Random rand = new Random (); private int hitpoints = 10; pubblico BlackHole (posizione Vector2) image = Art.BlackHole; Posizione = posizione; Raggio = image.Width / 2f;  public void WasShot () hitpoints--; se (punti d'impatto <= 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);  

I buchi neri prendono dieci colpi per uccidere. Regoliamo leggermente la scala dello sprite per farlo pulsare. Se decidi che distruggere buchi neri dovrebbe anche concedere punti, devi fare aggiustamenti simili al Buco nero classe come abbiamo fatto con la classe nemica.

Quindi faremo in modo che i buchi neri applichino effettivamente una forza su altre entità. Avremo bisogno di un piccolo metodo di supporto dal nostro EntityManager.

 public static IEnumerable GetNearbyEntities (posizione Vector2, raggio float) return entities.Where (x => Vector2.DistanceSquared (position, x.Position) < radius * radius); 

Questo metodo potrebbe essere reso più efficiente utilizzando uno schema di partizionamento spaziale più complicato, ma per il numero di entità che avremo, andrà bene così com'è. Ora possiamo fare in modo che i buchi neri applichino forza nelle loro Aggiornare() metodo.

 public override void Update () var entities = EntityManager.GetNearbyEntities (Position, 250); foreach (entità var in entità) if (entity is Enemy &&! (entity as Enemy) .IsActive) continua; // i proiettili vengono respinti dai buchi neri e tutto il resto viene attratto se (entità è Bullet) entità. Veloce + = (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)); 

I buchi neri influenzano solo le entità all'interno di un raggio scelto (250 pixel). I proiettili all'interno di questo raggio hanno una forza repulsiva costante applicata, mentre tutto il resto ha una forza di attrazione lineare applicata.

Avremo bisogno di aggiungere la gestione delle collisioni per i buchi neri al EntityManager. Aggiungere un List <> per buchi neri come abbiamo fatto per gli altri tipi di entità e aggiungere il seguente codice in EntityManager.HandleCollisions ().

 // gestisce le collisioni con buchi neri per (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;  

Infine, apri il EnemySpawner classe e fargli creare dei buchi neri. Ho limitato il numero massimo di buchi neri a due e ho dato 1 su 600 di possibilità che un buco nero generasse ogni fotogramma.

 if (EntityManager.BlackHoleCount < 2 && rand.Next((int)inverseBlackHoleChance) == 0) EntityManager.Add(new BlackHole(GetSpawnPosition()));

Conclusione

Abbiamo aggiunto la fioritura utilizzando vari shader e buchi neri utilizzando varie formule di forza. Shape Blaster sta iniziando a sembrare abbastanza buono. Nella parte successiva, aggiungeremo alcuni effetti pazzeschi sopra le particelle.