Come usare uno shader per scambiare dinamicamente i colori di uno sprite

In questo tutorial creeremo un semplice shader di scambio colore in grado di ricolorare gli sprite al volo. Lo shader rende molto più facile aggiungere varietà a un gioco, consente al giocatore di personalizzare il proprio personaggio e può essere usato per aggiungere effetti speciali agli sprite, come farli lampeggiare quando il personaggio subisce danni.

Anche se stiamo usando Unity per la demo e il codice sorgente qui, il principio di base funzionerà in molti motori di gioco e linguaggi di programmazione.

dimostrazione

Puoi vedere la demo di Unity o la versione WebGL (25MB +), per vedere il risultato finale in azione. Usa i selettori di colori per ricolorare il personaggio principale. (Gli altri personaggi usano tutti lo stesso sprite, ma sono stati ricolorato in modo simile.) Click Hit Effect per far lampeggiare brevemente tutti i personaggi bianchi.

Capire la teoria

Ecco la trama di esempio che utilizzeremo per dimostrare lo shader:

Ho scaricato questa texture da http://opengameart.org/content/classic-hero e l'ho modificata leggermente.

Ci sono alcuni colori su questa texture. Ecco come appare la tavolozza:

Ora, pensiamo a come possiamo scambiare questi colori all'interno di uno shader.

Ad ogni colore è associato un valore RGB univoco, quindi è allettante scrivere codice shader che dice "se il colore della trama è uguale a Questo Valore RGB, sostituirlo con quello Valore RGB ".Tuttavia, questo non si adatta bene a molti colori, ed è un'operazione piuttosto costosa. Vorremmo assolutamente evitare qualsiasi dichiarazione condizionale interamente, infatti.

Invece, useremo una trama aggiuntiva, che conterrà i colori di sostituzione. Chiamiamo questa texture a scambiare la trama.

La grande domanda è, come possiamo collegare il colore dalla texture sprite al colore della texture di swap? La risposta è, useremo il componente rosso (R) dal colore RGB per indicizzare la trama dello scambio. Ciò significa che la trama dello swap dovrà avere una larghezza di 256 pixel, perché è il numero di valori diversi che il componente rosso può assumere.

Esaminiamo tutto questo in un esempio. Ecco i valori del colore rosso dei colori della tavolozza dello sprite:

Diciamo che vogliamo sostituire il contorno / colore degli occhi (nero) sullo sprite con il colore blu. Il colore del contorno è l'ultimo sulla tavolozza, quello con un valore rosso di 25. Se vogliamo cambiare questo colore, allora nella trama dello swap dobbiamo impostare il pixel all'indice 25 sul colore che vogliamo che il contorno sia: blu.

La trama di scambio, con il colore all'indice 25 impostato su blu.

Ora, quando lo shader incontra un colore con un valore rosso di 25, lo sostituirà con il colore blu dalla trama dello swap:

Nota che questo potrebbe non funzionare come previsto se due o più colori sulla trama dello sprite condividono lo stesso valore rosso! Quando si utilizza questo metodo, è importante mantenere diversi i valori rossi dei colori nella texture sprite.

Nota inoltre che, come puoi vedere nella demo, l'inserimento di un pixel trasparente in qualsiasi indice nella trama dello swap non comporterà lo scambio di colore per i colori corrispondenti a quell'indice.

Implementazione dello shader

Implementeremo questa idea modificando uno shader di sprite esistente. Dal momento che il progetto demo è realizzato in Unity, userò lo sprite shader di Unity predefinito.

Tutto lo shader di default (che è rilevante per questo tutorial) è quello di campionare il colore dall'atlante di texture principale e moltiplicare quel colore con un colore di vertice per cambiare la tonalità. Il colore risultante viene quindi moltiplicato per l'alfa, per rendere lo sprite più scuro a opacità inferiori.

La prima cosa che dobbiamo fare è aggiungere una texture aggiuntiva allo shader:

Proprietà [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white"  _SwapTex ("Color Data", 2D) = "transparent"  _Color ("Tint", Color) = (1,1,1 , 1) [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0

Come puoi vedere, abbiamo due texture qui ora. Il primo, _MainTex, è la trama dello sprite; il secondo, _SwapTex, è la trama dello scambio.

Dobbiamo anche definire un campionatore per la seconda trama, in modo che possiamo effettivamente accedervi. Useremo un campionatore di texture 2D, poiché Unity non supporta i campionatori 1D:

sampler2D _MainTex; sampler2D _AlphaTex; float _AlphaSplitEnabled; sampler2D _SwapTex;

Ora possiamo finalmente modificare lo shader del frammento:

fixed4 SampleSpriteTexture (float2 uv) fixed4 color = tex2D (_MainTex, uv); if (_AlphaSplitEnabled) color.a = tex2D (_AlphaTex, uv) .r; restituire colore;  fixed4 frag (v2f IN): SV_Target fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color; c.rgb * = c.a; ritorno c; 

Ecco il codice rilevante per lo shader di frammenti predefinito. Come potete vedere, c è il colore campionato dalla trama principale; è moltiplicato per il colore del vertice per dargli una tinta. Inoltre, lo shader oscura gli sprite con opacità inferiori.

Dopo aver provato il colore principale, proviamo anche il colore dello swap, ma prima di farlo rimuoviamo la parte che lo moltiplica per il colore della tinta, in modo da campionare usando il valore reale del rosso della texture, non quello colorato.

fixed4 frag (v2f IN): SV_Target fixed4 c = SampleSpriteTexture (IN.texcoord); fixed4 swapCol = tex2D (_SwapTex, float2 (c.r, 0));

Come puoi vedere, l'indice di colore campionato è uguale al valore rosso del colore principale.

Ora calcoliamo il nostro colore finale:

fixed4 frag (v2f IN): SV_Target fixed4 c = SampleSpriteTexture (IN.texcoord); fixed4 swapCol = tex2D (_SwapTex, float2 (c.r, 0)); fixed4 final = lerp (c, swapCol, swapCol.a); 

Per fare ciò, dobbiamo interpolare tra il colore principale e il colore scambiato usando l'alfa del colore scambiato come passo. In questo modo, se il colore scambiato è trasparente, il colore finale sarà uguale al colore principale; ma se il colore scambiato è completamente opaco, il colore finale sarà uguale al colore scambiato.

Non dimentichiamo che il colore finale deve essere moltiplicato per la tinta:

fixed4 frag (v2f IN): SV_Target fixed4 c = SampleSpriteTexture (IN.texcoord); fixed4 swapCol = tex2D (_SwapTex, float2 (c.r, 0)); fixed4 final = lerp (c, swapCol, swapCol.a) * IN.color;

Ora dobbiamo considerare cosa dovrebbe accadere se vogliamo scambiare un colore sulla trama principale che non è completamente opaco. Ad esempio, se abbiamo uno sprite fantasma blu e semitrasparente e vogliamo cambiare il suo colore in viola, non vogliamo che il fantasma con i colori scambiati sia opaco, vogliamo preservare la trasparenza originale. Quindi facciamolo:

fixed4 frag (v2f IN): SV_Target fixed4 c = SampleSpriteTexture (IN.texcoord); fixed4 swapCol = tex2D (_SwapTex, float2 (c.r, 0)); fixed4 final = lerp (c, swapCol, swapCol.a) * IN.color; final.a = c.a;

La trasparenza del colore finale dovrebbe essere uguale alla trasparenza del colore della trama principale. 

Infine, poiché lo shader originale stava moltiplicando il valore RGB del colore per l'alfa del colore, dovremmo farlo anche per mantenere lo shader lo stesso:

fixed4 frag (v2f IN): SV_Target fixed4 c = SampleSpriteTexture (IN.texcoord); fixed4 swapCol = tex2D (_SwapTex, float2 (c.r, 0)); fixed4 final = lerp (c, swapCol, swapCol.a) * IN.color; final.a = c.a; final.rgb * = c.a; ritorno finale; 

Lo shader è completo ora; possiamo creare una texture a colori di scambio, riempirla con pixel di colore diversi e vedere se lo sprite cambia i colori correttamente. 

Ovviamente, questo metodo non sarebbe molto utile se dovessimo creare sempre textures di scambio a mano! Vorremo generarli e modificarli proceduralmente ...

Impostazione di una demo di esempio

Sappiamo che abbiamo bisogno di una trama di scambio per poter utilizzare il nostro shader. Inoltre, se vogliamo consentire a più personaggi di utilizzare diverse tavolozze per lo stesso sprite allo stesso tempo, ognuno di questi caratteri avrà bisogno della propria trama di scambio. 

Sarà meglio, quindi, se semplicemente creiamo queste trame di scambio dinamicamente, mentre creiamo gli oggetti.

Innanzitutto, definiamo una trama di scambio e una matrice in cui terremo traccia di tutti i colori scambiati:

Texture2D mColorSwapTex; Color [] mSpriteColors;

Quindi, creiamo una funzione in cui inizializzeremo la trama. Useremo il formato RGBA32 e imposteremo la modalità filtro su Punto:

public void InitColorSwapTex () Texture2D colorSwapTex = new Texture2D (256, 1, TextureFormat.RGBA32, false, false); colorSwapTex.filterMode = FilterMode.Point; 

Ora assicuriamoci che tutti i pixel della trama siano trasparenti, cancellando tutti i pixel e applicando le modifiche:

per (int i = 0; i < colorSwapTex.width; ++i) colorSwapTex.SetPixel(i, 0, new Color(0.0f, 0.0f, 0.0f, 0.0f)); colorSwapTex.Apply();

Abbiamo anche bisogno di impostare la texture di scambio del materiale su quella appena creata:

mSpriteRenderer.material.SetTexture ("_ SwapTex", colorSwapTex);

Infine, salviamo il riferimento alla trama e creiamo la matrice per i colori:

mSpriteColors = new Color [colorSwapTex.width]; mColorSwapTex = colorSwapTex;

La funzione completa è la seguente:

public void InitColorSwapTex () Texture2D colorSwapTex = new Texture2D (256, 1, TextureFormat.RGBA32, false, false); colorSwapTex.filterMode = FilterMode.Point; per (int i = 0; i < colorSwapTex.width; ++i) colorSwapTex.SetPixel(i, 0, new Color(0.0f, 0.0f, 0.0f, 0.0f)); colorSwapTex.Apply(); mSpriteRenderer.material.SetTexture("_SwapTex", colorSwapTex); mSpriteColors = new Color[colorSwapTex.width]; mColorSwapTex = colorSwapTex; 

Si noti che non è necessario che ciascun oggetto utilizzi una trama 256x1 px separata; potremmo creare una trama più grande che copra tutti gli oggetti. Se abbiamo bisogno di 32 caratteri, potremmo fare una trama di dimensioni 256x32px e assicurarci che ogni personaggio usi solo una riga specifica in quella texture. Tuttavia, ogni volta che dovevamo apportare una modifica a questa trama più grande, dovevamo passare più dati alla GPU, il che probabilmente renderebbe questo meno efficiente.

Non è inoltre necessario utilizzare una trama di scambio separata per ogni sprite. Ad esempio, se il personaggio ha un'arma equipaggiata e quell'arma è uno sprite separato, allora può facilmente condividere la trama dello scambio con il personaggio (a patto che la trama dello sprite dell'arma non usi colori con valori rossi identici a quelli dello sprite del personaggio).

È molto utile sapere quali sono i valori rossi di particolari parti sprite, quindi creiamo un enum che conserverà questi dati:

public enum SwapIndex Outline = 25, SkinPrim = 254, SkinSec = 239, HandPrim = 235, HandSec = 204, ShirtPrim = 62, ShirtSec = 70, ShoePrim = 253, ShoeSec = 248, Pantaloni = 72,

Questi sono tutti i colori usati dal carattere di esempio.

Ora abbiamo tutto ciò di cui abbiamo bisogno per creare una funzione per scambiare effettivamente il colore:

pubblico vuoto SwapColor (indice SwapIndex, Colore colore) mSpriteColors [(int) index] = color; mColorSwapTex.SetPixel ((int) index, 0, color); 

Come puoi vedere, non c'è niente di speciale qui; abbiamo semplicemente impostato il colore nell'array dei colori dell'oggetto e impostato anche il pixel della trama in un indice appropriato. 

Nota che non vogliamo applicare le modifiche alla trama ogni volta che chiamiamo questa funzione; preferiremmo applicarli una volta scambiati tutti i pixel che vogliamo.

Diamo un'occhiata a un esempio di utilizzo della funzione:

 SwapColor (SwapIndex.SkinPrim, ColorFromInt (0x784a00)); SwapColor (SwapIndex.SkinSec, ColorFromInt (0x4c2d00)); SwapColor (SwapIndex.ShirtPrim, ColorFromInt (0xc4ce00)); SwapColor (SwapIndex.ShirtSec, ColorFromInt (0x784a00)); SwapColor (SwapIndex.Pants, ColorFromInt (0x594f00)); mColorSwapTex.Apply ();

Come puoi vedere, è abbastanza facile capire cosa stanno facendo queste chiamate di funzione solo leggendole: in questo caso stanno cambiando sia i colori della pelle, sia i colori della maglietta, sia il colore dei pantaloni.

Aggiunta di un effetto colpo alla demo

Vediamo ora come possiamo usare lo shader per creare un effetto di successo per il nostro sprite. Questo effetto cambierà i colori dello sprite in bianco, lo manterrà in questo modo per un breve periodo di tempo e poi tornerà al colore originale. L'effetto complessivo sarà che lo sprite lampeggi bianco.

Prima di tutto, creiamo una funzione che scambia tutti i colori, ma in realtà non sovrascrive i colori dall'array dell'oggetto. Avremo bisogno di questi colori quando vorremmo disattivare l'effetto hit, dopo tutto.

public void SwapAllSpritesColorsTemporarily (Color color) for (int i = 0; i < mColorSwapTex.width; ++i) mColorSwapTex.SetPixel(i, 0, color); mColorSwapTex.Apply(); 

Potremmo scorrere solo attraverso l'enumerazione, ma l'iterazione attraverso l'intera trama assicurerà che il colore venga scambiato anche se un determinato colore non è definito nel SwapIndex.

Ora che i colori vengono scambiati, dobbiamo aspettare un po 'di tempo e tornare ai colori precedenti. 

Per prima cosa, creiamo una funzione che ripristini i colori:

pubblico vuoto ResetAllSpritesColors () for (int i = 0; i < mColorSwapTex.width; ++i) mColorSwapTex.SetPixel(i, 0, mSpriteColors[i]); mColorSwapTex.Apply(); 

Ora definiamo il timer e una costante:

float mHitEffectTimer = 0.0f; const float cHitEffectTime = 0.1f;

Creiamo una funzione che avvii l'effetto hit:

public void StartHitEffect () mHitEffectTimer = cHitEffectTime; SwapAllSpritesColorsTemporarily (Color.white); 

E nella funzione di aggiornamento, controlliamo quanto tempo è rimasto sul timer, diminuiamo ogni tick e chiediamo un reset quando il tempo è scaduto:

public void Update () if (mHitEffectTimer> 0.0f) mHitEffectTimer - = Time.deltaTime; if (mHitEffectTimer <= 0.0f) ResetAllSpritesColors();  

È così, ora, quando StartHitEffect è chiamato, lo sprite lampeggerà in bianco per un momento e poi tornerà ai suoi colori precedenti.

Sommario

Questo segna la fine del tutorial! Spero che tu trovi il metodo accettabile e che lo shader sia utile. È davvero semplice, ma funziona bene per gli sprite di pixel art che non usano molti colori. 

Il metodo dovrebbe essere modificato un po 'se volessimo scambiare interi gruppi di colori contemporaneamente, il che richiederebbe sicuramente uno shader più complicato e costoso. Nel mio gioco, però, sto usando pochissimi colori, quindi questa tecnica si adatta perfettamente.