Cómo usar un Shader para intercambiar dinámicamente los colores de un Sprite

En este tutorial, crearemos un sombreador de intercambio de color simple que puede recolorear sprites sobre la marcha. El shader hace que sea mucho más fácil agregar variedad a un juego, permite que el jugador personalice a su personaje y se puede usar para agregar efectos especiales a los sprites, como hacerlos parpadear cuando el personaje recibe daño..

Aunque aquí usamos Unity para la demo y el código fuente, el principio básico funcionará en muchos motores de juego y lenguajes de programación..

Manifestación

Puede ver la demostración de Unity, o la versión de WebGL (25MB +), para ver el resultado final en acción. Usa los selectores de color para recolorear el carácter superior. (Todos los otros caracteres usan el mismo sprite, pero se han recolocado de manera similar). Efecto de golpe para hacer que los personajes se iluminen brevemente.

Entendiendo la teoria

Este es el ejemplo de textura que usaremos para demostrar el sombreado:

Descargué esta textura de http://opengameart.org/content/classic-hero, y la edité ligeramente.

Hay bastantes colores en esta textura. Así es como se ve la paleta:

Ahora, pensemos en cómo podríamos intercambiar estos colores dentro de un sombreador.

Cada color tiene un valor RGB único asociado, por lo que es tentador escribir un código de sombreado que diga "si el color de la textura es igual a esta Valor RGB, reemplazarlo con ese Valor RGB ". Sin embargo, esto no se adapta bien a muchos colores y es una operación bastante costosa. Definitivamente, nos gustaría evitar las declaraciones condicionales por completo, de hecho.

En su lugar, utilizaremos una textura adicional, que contendrá los colores de reemplazo. Llamemos a esta textura una textura de intercambio.

La gran pregunta es, ¿cómo vinculamos el color de la textura del sprite al color de la textura de intercambio? La respuesta es que usaremos el componente rojo (R) del color RGB para indexar la textura de intercambio. Esto significa que la textura de intercambio tendrá que tener 256 píxeles de ancho, porque así es como muchos valores diferentes puede tomar el componente rojo.

Repasemos todo esto en un ejemplo. Aquí están los valores de color rojo de los colores de la paleta de sprites:

Digamos que queremos reemplazar el contorno / color de ojos (negro) en el sprite con el color azul. El color del contorno es el último en la paleta, el que tiene un valor rojo de 25. Si queremos intercambiar este color, en la textura de intercambio necesitamos establecer el píxel en el índice 25 al color que queremos que sea el contorno: azul.

La textura de intercambio, con el color en el índice 25 establecido en azul.

Ahora, cuando el shader encuentra un color con un valor rojo de 25, lo reemplazará con el color azul de la textura de intercambio:

Tenga en cuenta que esto puede no funcionar como se espera si dos o más colores en la textura del sprite comparten el mismo valor de rojo. Al usar este método, es importante mantener diferentes los valores rojos de los colores en la textura del sprite.

También tenga en cuenta que, como puede ver en la demostración, colocar un píxel transparente en cualquier índice en la textura de intercambio no dará como resultado el intercambio de color para los colores correspondientes a ese índice.

Implementando el Shader

Implementaremos esta idea modificando un sombreador de sprite existente. Dado que el proyecto de demostración se realiza en Unity, usaré el sombreador de sprites de Unity predeterminado.

Todo lo que hace el sombreador predeterminado (que es relevante para este tutorial) es muestrear el color del atlas de textura principal y multiplicar ese color por un color de vértice para cambiar el tinte. El color resultante se multiplica por el alfa, para oscurecer el sprite en opacidades más bajas..

Lo primero que debemos hacer es agregar una textura adicional al sombreador:

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

Como puedes ver, tenemos dos texturas aquí ahora. El primero, _MainTex, es la textura del sprite; el segundo, _SwapTex, es la textura de intercambio.

También necesitamos definir una muestra para la segunda textura, para que podamos acceder a ella. Usaremos un muestreador de texturas 2D, ya que Unity no admite muestreadores 1D:

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

Ahora podemos finalmente editar el sombreador de fragmentos:

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

Aquí está el código relevante para el sombreador de fragmentos predeterminado. Como puedes ver, do es el color muestreado de la textura principal; se multiplica por el color del vértice para darle un tinte. Además, el shader oscurece los sprites con opacidades inferiores..

Después de muestrear el color principal, también muestremos el color de intercambio, pero antes de hacerlo, eliminemos la parte que lo multiplica por el color del tinte, de modo que estemos muestreando usando el valor rojo real de la textura, no el color rojo..

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

Como puede ver, el índice de color muestreado es igual al valor rojo del color principal.

Ahora vamos a calcular nuestro color final:

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

Para hacer esto, necesitamos interpolar entre el color principal y el color intercambiado utilizando el alfa del color intercambiado como paso. De esta manera, si el color intercambiado es transparente, el color final será igual al color principal; pero si el color intercambiado es completamente opaco, entonces el color final será igual al color intercambiado.

No olvidemos que el color final debe ser multiplicado por el tinte:

fix4 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;

Ahora debemos considerar qué debería suceder si queremos intercambiar un color en la textura principal que no sea completamente opaco. Por ejemplo, si tenemos un sprite fantasma azul semitransparente y queremos cambiar su color a púrpura, no queremos que el fantasma con los colores intercambiados sea opaco, queremos preservar la transparencia original. Así que vamos a hacer eso:

fix4 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 transparencia del color final debe ser igual a la transparencia del color de la textura principal. 

Finalmente, dado que el sombreador original estaba multiplicando el valor RGB del color por el alfa del color, deberíamos hacerlo también, para mantener el sombreado igual:

fix4 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; retorno final; 

El shader está completo ahora; podemos crear una textura de color de intercambio, rellenarla con píxeles de colores diferentes y ver si el sprite cambia los colores correctamente. 

Por supuesto, este método no sería muy útil si tuviéramos que crear texturas de intercambio a mano todo el tiempo. Queremos generarlos y modificarlos de forma procedimental ...

Configuración de un ejemplo de demostración

Sabemos que necesitamos una textura de intercambio para poder utilizar nuestro sombreado. Además, si queremos que varios caracteres utilicen diferentes paletas para el mismo sprite al mismo tiempo, cada uno de estos personajes necesitará su propia textura de intercambio.. 

Será mejor, entonces, si simplemente creamos estas texturas de intercambio dinámicamente, al crear los objetos..

En primer lugar, definamos una textura de intercambio y una matriz en la que realizaremos un seguimiento de todos los colores intercambiados:

Texture2D mColorSwapTex; Color [] mSpriteColors;

A continuación, vamos a crear una función en la que inicializaremos la textura. Usaremos el formato RGBA32 y estableceremos el modo de filtro en Punto:

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

Ahora asegurémonos de que todos los píxeles de la textura sean transparentes, borrando todos los píxeles y aplicando los cambios:

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

También debemos configurar la textura de intercambio del material con la que se acaba de crear:

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

Finalmente, guardamos la referencia a la textura y creamos la matriz para los colores:

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

La función completa es la siguiente:

public void InitColorSwapTex () Texture2D colorSwapTex = new Texture2D (256, 1, TextureFormat.RGBA32, false, false); colorSwapTex.filterMode = FilterMode.Point; para (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; 

Tenga en cuenta que no es necesario que cada objeto utilice una textura de 256x1px separada; Podríamos hacer una textura más grande que cubra todos los objetos. Si necesitamos 32 caracteres, podríamos hacer una textura de tamaño 256x32px y asegurarnos de que cada carácter use solo una fila específica en esa textura. Sin embargo, cada vez que necesitábamos hacer un cambio a esta textura más grande, tendríamos que pasar más datos a la GPU, lo que probablemente haría que esto fuera menos eficiente.

Tampoco es necesario utilizar una textura de intercambio separada para cada sprite. Por ejemplo, si el personaje tiene un arma equipada, y esa arma es un sprite separado, entonces puede compartir fácilmente la textura de intercambio con el personaje (siempre y cuando la textura del sprite del arma no use colores que tengan valores rojos idénticos a aquellos del personaje sprite).

Es muy útil saber cuáles son los valores rojos de partes de sprite particulares, así que vamos a crear una enumerar que mantendrá estos datos:

SwapIndex público enumeración Outline = 25, SkinPrim = 254, SkinSec = 239, HandPrim = 235, HandSec = 204, ShirtPrim = 62, ShirtSec = 70, ShoePrim = 253, ShoeSec = 248, Pants = 72,

Estos son todos los colores utilizados por el personaje de ejemplo..

Ahora tenemos todas las cosas que necesitamos para crear una función para intercambiar el color:

public void SwapColor (índice SwapIndex, color del color) mSpriteColors [(int) index] = color; mColorSwapTex.SetPixel ((int) index, 0, color); 

Como puedes ver, no hay nada lujoso aquí; simplemente establecemos el color en la matriz de color de nuestro objeto y también establecemos el píxel de la textura en un índice apropiado. 

Tenga en cuenta que no queremos aplicar los cambios a la textura cada vez que llamemos a esta función; Preferiríamos aplicarlos una vez que cambiemos todos los píxeles que queremos.

Veamos un ejemplo de uso de la función:

 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 ();

Como puede ver, es bastante fácil entender lo que hacen estas llamadas de función solo con leerlas: en este caso, están cambiando el color de la piel, el color de la camisa y el color de los pantalones..

Agregando un efecto de golpe a la demo

Veamos a continuación cómo podemos usar el shader para crear un efecto de impacto para nuestro sprite. Este efecto cambiará todos los colores del sprite a blanco, lo mantendrá así durante un breve período de tiempo y luego volverá al color original. El efecto general será que el sprite parpadea en blanco..

En primer lugar, vamos a crear una función que intercambie todos los colores, pero en realidad no sobrescribe los colores de la matriz del objeto. Necesitaremos estos colores cuando deseamos desactivar el efecto de golpe, después de todo.

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

Podríamos iterar solo a través de los enumerados, pero iterar a través de toda la textura asegurará que el color se intercambie incluso si un color en particular no está definido en el SwapIndex.

Ahora que los colores están intercambiados, debemos esperar un tiempo y volver a los colores anteriores.. 

Primero, vamos a crear una función que restablecerá los colores:

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

Ahora definamos el temporizador y una constante:

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

Vamos a crear una función que inicie el efecto de golpe:

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

Y en la función de actualización, verifiquemos cuánto tiempo le queda al temporizador, disminuyámosla cada vez que haga clic y pidamos un restablecimiento cuando se acabe el tiempo:

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

Eso es todo, ahora, cuando. StartHitEffect se llama, el sprite parpadeará en blanco por un momento y luego volverá a sus colores anteriores.

Resumen

Esto marca el final del tutorial! Espero que encuentres el método aceptable y el shader útil. Es realmente simple, pero funciona bien para sprites de pixel art que no usan muchos colores. 

El método tendría que cambiarse un poco si quisiéramos intercambiar grupos enteros de colores a la vez, lo que definitivamente requeriría un sombreado más complicado y costoso. En mi propio juego, sin embargo, estoy usando muy pocos colores, por lo que esta técnica encaja perfectamente.