Advertisement
  1. Game Development
  2. Shaders

Cómo utilizar un sombreador para intercambiar dinámicamente los colores de un sprite

Scroll to top
Read Time: 12 min

() translation by (you can also view the original English article)

En este tutorial, crearemos un sencillo sombreador de intercambio de colores que puede recolorear los sprites de forma inmediata. El sombreado facilita mucho la variedad de un juego, permite al jugador personalizar su personaje y puede utilizarse para añadir efectos especiales a los sprites, como hacerlos parpadear cuando el personaje recibe daño.

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

Demo

Puedes consultar la demo de Unity, o la versión WebGL (25MB+), para ver el resultado final en acción. Utiliza los selectores de color para recolorear el carácter superior. (Todos los demás personajes utilizan el mismo sprite, pero han sido recoloreados de forma similar). Haz clic en Efecto de golpe para que todos los caracteres parpadeen brevemente en blanco.

Entender la teoría

Aquí está la textura de ejemplo que vamos a utilizar para demostrar el shader:

Example textureExample textureExample texture
He descargado esta textura de http://opengameart.org/content/classic-hero, y la he editado ligeramente.

Hay bastantes colores en esta textura. Este es el aspecto de la paleta:

A paletteA paletteA palette

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

Cada color tiene un valor RGB único asociado a él, por lo que es tentador escribir un código de sombreado que diga "si el color de la textura es igual a este valor RGB, reemplázalo por ese valor RGB". Sin embargo, esto no se adapta bien a muchos colores y es una operación bastante cara. De hecho, nos gustaría evitar por completo cualquier declaración condicional.

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

La gran pregunta es, ¿cómo vinculamos el color de la textura del sprite con el color de la textura de intercambio? La respuesta es que utilizaremos el componente rojo (R) del color RGB para indexar la textura de intercambio. Esto significa que la textura de intercambio tendrá que ser de 256 píxeles de ancho, porque ese es el número de valores diferentes que puede tomar el componente rojo.

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

R color values for paletteR color values for paletteR color values for palette

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

Swap texture with blue highlightedSwap texture with blue highlightedSwap texture with blue highlighted
La textura de intercambio, con el color en el índice 25 fijado en azul.

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

The swap texture in actionThe swap texture in actionThe swap texture in action

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

También hay que tener en cuenta que, como se puede ver en la demostración, si se pone un píxel transparente en cualquier índice de la textura de intercambio, no habrá intercambio de color para los colores correspondientes a ese índice.

Implementación del sombreado

Implementaremos esta idea modificando un shader de sprites existente. Como el proyecto de demostración está hecho en Unity, usaré el shader de sprites por defecto de Unity.

Todo lo que hace el shader por defecto (que es relevante para este tutorial) es muestrear el color del atlas de la 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 que el sprite sea más oscuro en las opacidades más bajas.

Lo primero que tenemos que hacer es añadir una textura adicional al shader:

1
Properties
2
{
3
	[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
4
	_SwapTex("Color Data", 2D) = "transparent" {}
5
	_Color ("Tint", Color) = (1,1,1,1)
6
	[MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
7
}

Como puedes ver, ahora tenemos dos texturas aquí. La primera, _MainTex, es la textura del sprite; la segunda, _SwapTex, es la textura de intercambio.

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

1
sampler2D _MainTex;
2
sampler2D _AlphaTex;
3
float _AlphaSplitEnabled;
4
5
sampler2D _SwapTex;

Ahora podemos finalmente editar el fragment shader:

1
fixed4 SampleSpriteTexture (float2 uv)
2
{
3
	fixed4 color = tex2D (_MainTex, uv);
4
	if (_AlphaSplitEnabled)
5
		color.a = tex2D (_AlphaTex, uv).r;
6
7
	return color;
8
}
9
            
10
fixed4 frag(v2f IN) : SV_Target
11
{
12
	fixed4 c = SampleSpriteTexture (IN.texcoord) * IN.color;
13
	c.rgb *= c.a;
14
	return c;
15
}

Aquí está el código relevante para el fragment shader por defecto. Como puedes ver, c es el color muestreado de la textura principal; se multiplica por el color del vértice para darle un matiz. Además, el shader oscurece los sprites con menor opacidad.

Después de muestrear el color principal, vamos a muestrear también el color de intercambio, pero antes de hacerlo, vamos a eliminar la parte que lo multiplica por el color de tinte, para que estemos muestreando utilizando el valor real de rojo de la textura, no su tinte.

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

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

Ahora vamos a calcular nuestro color final:

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

Para ello, tenemos que 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 totalmente opaco, entonces el color final será igual al color intercambiado.

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

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

Ahora tenemos que considerar lo que debe suceder si queremos cambiar un color en la textura principal que no es totalmente 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 conservar la transparencia original. Así que hagamos eso:

1
fixed4 frag(v2f IN) : SV_Target
2
{
3
    fixed4 c = SampleSpriteTexture (IN.texcoord);
4
    fixed4 swapCol = tex2D(_SwapTex, float2(c.r, 0));
5
    fixed4 final = lerp(c, swapCol, swapCol.a) * IN.color;
6
    final.a = c.a;

La transparencia del color final debe ser igual a la transparencia del color principal de la textura.

Por último, ya que el shader original multiplicaba el valor RGB del color por el alfa del mismo, debemos hacerlo también para que el shader siga siendo el mismo:

1
fixed4 frag(v2f IN) : SV_Target
2
{
3
	fixed4 c = SampleSpriteTexture (IN.texcoord);
4
	fixed4 swapCol = tex2D(_SwapTex, float2(c.r, 0));
5
	fixed4 final = lerp(c, swapCol, swapCol.a) * IN.color;
6
	final.a = c.a;
7
	final.rgb *= c.a;
8
	return final;
9
}

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

¡Por supuesto, este método no sería muy útil si tuviéramos que crear texturas de intercambio a mano todo el tiempo! Querremos generarlas y modificarlas procedimentalmente...

Configuración de un ejemplo de demostración

Sabemos que necesitamos una textura de intercambio para poder hacer uso de nuestro shader. Además, si queremos que varios personajes 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, a medida que creamos los objetos.

En primer lugar, vamos a definir una textura de intercambio y un array en el que guardaremos todos los colores intercambiados:

1
Texture2D mColorSwapTex;
2
Color[] mSpriteColors;

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

1
public void InitColorSwapTex()
2
{
3
    Texture2D colorSwapTex = new Texture2D(256, 1, TextureFormat.RGBA32, false, false);
4
    colorSwapTex.filterMode = FilterMode.Point;
5
}

Ahora vamos a asegurarnos de que todos los píxeles de la textura son transparentes, borrando todos los píxeles y aplicando los cambios:

1
for (int i = 0; i < colorSwapTex.width; ++i)
2
    colorSwapTex.SetPixel(i, 0, new Color(0.0f, 0.0f, 0.0f, 0.0f));
3
4
colorSwapTex.Apply();

También tenemos que establecer la textura de intercambio del material a la recién creada:

1
mSpriteRenderer.material.SetTexture("_SwapTex", colorSwapTex);

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

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

La función completa es la siguiente:

1
public void InitColorSwapTex()
2
{
3
    Texture2D colorSwapTex = new Texture2D(256, 1, TextureFormat.RGBA32, false, false);
4
    colorSwapTex.filterMode = FilterMode.Point;
5
6
    for (int i = 0; i < colorSwapTex.width; ++i)
7
        colorSwapTex.SetPixel(i, 0, new Color(0.0f, 0.0f, 0.0f, 0.0f));
8
9
    colorSwapTex.Apply();
10
11
    mSpriteRenderer.material.SetTexture("_SwapTex", colorSwapTex);
12
13
    mSpriteColors = new Color[colorSwapTex.width];
14
    mColorSwapTex = colorSwapTex;
15
}

Tenga en cuenta que no es necesario que cada objeto utilice una textura separada de 256x1px; 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 utiliza solo una fila específica de esa textura. Sin embargo, cada vez que necesitáramos hacer un cambio en esta textura más grande, tendríamos que pasar más datos a la GPU, lo que probablemente haría esto menos eficiente.

Tampoco es necesario utilizar una textura de intercambio distinta 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 que la textura del sprite del arma no utilice colores que tengan valores rojos idénticos a los del sprite del personaje).

Es muy útil saber cuáles son los valores de rojo de determinadas partes del sprite, así que vamos a crear un enum que contenga estos datos:

1
public enum SwapIndex
2
{
3
    Outline = 25,
4
    SkinPrim = 254,
5
    SkinSec = 239,
6
    HandPrim = 235,
7
    HandSec = 204,
8
    ShirtPrim = 62,
9
    ShirtSec = 70,
10
    ShoePrim = 253,
11
    ShoeSec = 248,
12
    Pants = 72,
13
}

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

Ahora tenemos todo lo que necesitamos para crear una función que realmente cambie el color:

1
public void SwapColor(SwapIndex index, Color color)
2
{
3
    mSpriteColors[(int)index] = color;
4
    mColorSwapTex.SetPixel((int)index, 0, color);
5
}

Como puedes ver, no hay nada de fantasía 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.

Ten en cuenta que en realidad no queremos aplicar los cambios a la textura cada vez que llamamos a esta función; preferimos aplicarlos una vez que cambiamos todos los píxeles que queremos.

Veamos un ejemplo de uso de la función:

1
 SwapColor(SwapIndex.SkinPrim, ColorFromInt(0x784a00));
2
SwapColor(SwapIndex.SkinSec, ColorFromInt(0x4c2d00));
3
SwapColor(SwapIndex.ShirtPrim, ColorFromInt(0xc4ce00));
4
SwapColor(SwapIndex.ShirtSec, ColorFromInt(0x784a00));
5
SwapColor(SwapIndex.Pants, ColorFromInt(0x594f00));
6
mColorSwapTex.Apply();

Como puedes ver, es bastante fácil entender lo que hacen estas llamadas a las funciones con sólo leerlas: en este caso, están cambiando ambos colores de piel, ambos colores de camisa y el color de los pantalones.

Añadir un efecto de golpe a la demostración

Veamos a continuación cómo podemos utilizar el shader para crear un efecto de golpe para nuestro sprite. Este efecto cambiará todos los colores del sprite a blanco, lo mantendrá así durante un breve periodo 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 que no sobrescriba los colores del array del objeto. Necesitaremos estos colores cuando queramos desactivar el efecto de golpe, después de todo.

1
public void SwapAllSpritesColorsTemporarily(Color color)
2
{
3
    for (int i = 0; i < mColorSwapTex.width; ++i)
4
        mColorSwapTex.SetPixel(i, 0, color);
5
    mColorSwapTex.Apply();
6
}

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

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

En primer lugar, vamos a crear una función que restablecerá los colores:

1
public void ResetAllSpritesColors()
2
{
3
    for (int i = 0; i < mColorSwapTex.width; ++i)
4
        mColorSwapTex.SetPixel(i, 0, mSpriteColors[i]);
5
    mColorSwapTex.Apply();
6
}

Ahora vamos a definir el temporizador y una constante:

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

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

1
public void StartHitEffect()
2
{
3
    mHitEffectTimer = cHitEffectTime;
4
    SwapAllSpritesColorsTemporarily(Color.white);
5
}

Y en la función de actualización, comprobemos cuánto tiempo le queda al temporizador, disminuyámoslo cada vez, y pidamos un reinicio cuando se acabe el tiempo:

1
public void Update()
2
{
3
    if (mHitEffectTimer > 0.0f)
4
    {
5
        mHitEffectTimer -= Time.deltaTime;
6
        if (mHitEffectTimer <= 0.0f)
7
            ResetAllSpritesColors();
8
    }
9
}

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

Resumen

¡Con esto termina el tutorial! Espero que encuentres el método aceptable y el shader útil. Es muy simple, pero funciona muy bien para los sprites de pixel art que no usan muchos colores.

Habría que cambiar un poco el método si quisiéramos intercambiar grupos enteros de colores a la vez, lo que definitivamente requeriría un shader más complicado y costoso. Sin embargo, en mi propio juego, utilizo muy pocos colores, por lo que esta técnica encaja perfectamente.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.