Advertisement
  1. Game Development
  2. Shaders

Como usar um Shader para trocar dinamicamente as cores de um Sprite

Scroll to top
Read Time: 11 min

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

Neste tutorial, vamos criar uma simples troca de cores para recolorir sprites em tempo de execução. O shader tornará mais fácil adicionar uma variedade ao jogo, permitindo ao jogador personalizar o seu personagem e pode ser usado para adicionar efeitos especiais nos sprites, como fazê-los piscar ao sofrer dano.

Embora estamos usando Unity para demonstrar o código fonte criado aqui, o princípio básico irá funcionar em muitos outros motores de jogo e linguagens de programação.

Demonstração

Você pode conferir a Demo na Unity, ou a versão de WebGL (25 MB+), para ver o resultado final em ação. Use o seletor de cores para recolorir o personagem. (Os outros personagens todos usam o mesmo sprite, mas foram recoloridos de forma similar.) Clique em Hit Effect para fazer os personagens piscar brevemente.

Entendendo a teoria

Esta é a textura de exemplo que nós vamos usar para demonstrar o shader:

Example textureExample textureExample texture
Eu baixei essa textura em http://opengameart.org/content/classic-hero e editei um pouco.

Há várias cores nesta textura. Esta é a paleta de cores:

A paletteA paletteA palette

Agora, vamos pensar como poderíamos trocar essas cores dentro de um shader.

Cada cor tem um valor RGB exclusivo associado a ela, portanto, é tentador escrever um shader que diz, "se a cor da textura é igual a esse valor RGB, substitua com esse valor RGB". No entanto, isto não funciona bem para muitas cores e é uma operação cara. Definitivamente queremos evitar quaisquer instruções condicionais.

Em vez disso, vamos usar uma textura adicional, que irá conter as cores de substituição. Vamos chamar esta textura de textura de troca.

A grande questão é, como podemos ligar a cor da textura do sprite à cor de textura de troca? A resposta é, utilizaremos o componente vermelho (R) das cores RGB para indexar a textura de troca. Isto significa que a textura de troca precisará ter 256 pixels de largura, porque este é o número de valores diferentes que o componente vermelho pode ter.

Vamos ver tudo isso em um exemplo. Aqui está o valor da cor vermelha para cada cor do sprite:

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

Digamos que queremos substituir a cor de contorno/olho (preta) pela cor azul. A cor de contorno é a última na paleta — aquela com um valor de vermelho de 25. Se nós queremos trocar essa cor, na textura de troca, precisamos definir o pixel no índice 25 para a cor que queremos que o contorno seja: azul.

Swap texture with blue highlightedSwap texture with blue highlightedSwap texture with blue highlighted
A textura de troca, com a cor no índice 25 definida como azul.

Agora, quando o shader encontra uma cor com um valor vermelho 25, ele substituirá com a cor azul na textura de troca:

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

Observe que isso pode não funcionar conforme o esperado se duas ou mais cores na textura do sprite compartilharem o mesmo valor de vermelho! Ao usar esse método, é importante manter os valores vermelhos das cores na textura do sprite diferente.

Observe também que, como você pode ver na demo, colocar um pixel transparente em qualquer índice na textura de troca irá resultar em nenhuma troca de cores no índice correspondente.

Implementando o shader

Implementaremos essa ideia modificando um shader de um sprite existente. Como o projeto de demonstração foi feito na Unity, eu vou usar o shader de sprite padrão na Unity.

Tudo o que o shader padrão faz (que é relevante para este tutorial) é pegar a cor do atlas de textura principal e multiplicar essa cor pela cor da vértice para alterar a tonalidade. A cor resultante é então multiplicada pelo alpha, para escurecer o sprite em baixa opacidade.

A primeira coisa que precisamos fazer é adicionar uma textura adicional para o 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 você pode ver, temos duas texturas agora. A primeira, _MainTex, é a textura do sprite; a segunda, _SwapTex, é a textura de troca.

Também precisamos definir uma amostra para a segunda textura, então nós realmente poderemos acessá-la. Nós vamos usar uma amostra de textura 2D, já que a Unity não suporta amostras 1D:

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

Agora podemos finalmente editar o shader de fragmento:

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
}

Aqui está o código relevante para o shader de fragmento padrão. Como você pode ver, c é a cor da amostra da textura principal; Ela é multiplicada pela cor do vértice para dar-lhe uma tonalidade. Além disso, o shader escurece os sprites com opacidades inferiores.

Após a amostragem da cor principal, vamos fazer isso com a cor de troca também — mas antes de fazermos isso, vamos remover a parte que multiplica a cor da tonalidade, para que nós usemos o valor de vermelho real da textura, e não um recolorido.

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

Como você pode ver, o índice de amostra de cor é igual ao valor vermelho da cor principal.

Agora vamos calcular a nossa cor 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 fazer isso, precisamos interpolar entre a cor principal e a cor de troca usando a alfa da cor trocada como meio. Desta forma, se cor de troca é transparente, a cor final será igual à cor principal; Mas se a cor trocada é completamente opaca, então a cor final será igual à cor trocada.

Não vamos esquecer que a cor final precisa ser multiplicada pela tonalidade:

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;

Agora precisamos considerar o que deve acontecer se nós queremos trocar uma cor na textura principal que não seja totalmente opaca. Por exemplo, se nós temos um sprite fantasma azul semi-transparente e quisermos trocar sua cor para roxo, não queremos que o fantasma fique opaco, e sim que preserve a transparência original. Então vamos fazer isso:

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;

A transparência da cor final deve ser igual à transparência da cor textura principal.

Finalmente, do mesmo jeito que o shader original multiplicou o valor da cor RGB pela alpha, devemos fazer isso também, para manter o shader com a transparência:

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
}

O shader está completo agora. Podemos criar uma textura de troca, preenchê-lo com pixels de cores diferentes e ver se o sprite muda as cores corretamente.

Claro, esse método não seria muito útil se nós tivemos que criar texturas manualmente o tempo todo! Vamos querer gerar e modificá-las processualmente...

Configurando uma demo de exemplo

Sabemos que precisamos de uma textura de troca para ser capaz de fazer uso de nosso shader. Além disso, se queremos deixar vários personagens usar paletas de cores diferentes para o mesmo sprite ao mesmo tempo, cada um desses personagens terá sua própria textura de troca.

Será melhor, então, se nós simplesmente criamos essas texturas de troca dinamicamente, como nós criamos os objetos.

Para começar, vamos definir uma textura de troca e uma matriz na qual manteremos o controle de todas as cores trocadas:

1
Texture2D mColorSwapTex;
2
Color[] mSpriteColors;

Em seguida, vamos criar uma função na qual vamos inicializar a textura. Nós usaremos o formato RGBA32 e definiremos o modo de filtro para Point:

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

Agora vamos certificar-se de que os pixels da textura são transparentes, limpando todos os pixels e aplicando as alterações:

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

Também precisamos definir a textura de troca do material recém-criado:

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

Finalmente, podemos salvar uma referência da textura e criar a matriz para as cores:

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

A função completa fica assim:

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
}

Note que não é necessário que cada objeto use uma textura de 256x1px; poderíamos fazer uma textura maior que abrange todos os objetos. Se precisamos de 32 personagens, podíamos fazer uma textura de tamanho 256x32px e certificar-se de que cada personagem usa apenas uma linha específica na textura. No entanto, cada vez que precisarmos para fazer uma mudança nessa textura maior, teríamos que passar mais dados para a GPU, o que provavelmente seria menos eficiente.

Também não é necessário usar uma textura de troca separada para cada sprite. Por exemplo, se o personagem tem uma arma equipada, e aquela arma é um sprite separado, então podemos facilmente compartilhar a textura de troca com o personagem (desde que a textura da arma não use cores com vermelhos idênticos ao sprite do personagem).

É muito útil saber quais são os valores vermelhos de cada parte do sprite, então vamos criar uma enum que armazenará esses dados:

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
}

Estas são todas as cores usadas pelo personagem exemplo.

Agora temos tudo que precisamos para criar uma função para realmente trocar a cor:

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

Como você pode ver, não há nada chique aqui; Nós apenas definimos a cor na matriz de cor do nosso objeto e colocamos o pixel da textura no índice apropriado.

Note que nós realmente não queremos aplicar as alterações para a textura cada vez que chamarmos essa função; em vez disso vamos aplicá-las uma única vez depois de termos alterado todos os pixels que queremos

Vamos ver um uso de exemplo da função:

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 você pode ver, é muito fácil de entender o que essas chamadas estão fazendo só de lê-las: neste caso, estão mudando as duas cores de pele, as cores de camisa e a cor da calça.

Adicionando um efeito de dano na Demo

Vejamos agora como podemos usar o shader para criar um efeito de dano no nosso sprite. Este efeito irá trocar todas as cores do sprite para branco, manter assim por um breve período de tempo e depois voltar a cor original. No final o sprite vai piscar na cor branca.

Em primeiro lugar, vamos criar uma função que troca todas as cores, mas na verdade não substitui as cores da matriz do objeto. Vamos precisar dessas cores quando quisermos desligar efeito, afinal.

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
}

Nós poderíamos apenas iterar os enums, mas iterar pela textura inteira irá garantir que a cor será trocada, mesmo se uma determinada cor não está definida no SwapIndex.

Agora que as cores estão trocadas, precisamos esperar algum tempo e retornar de volta para as cores anteriores.

Primeiro, vamos criar uma função que irá repor as cores:

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
}

Agora vamos definir uma constante para o tempo.

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

Vamos criar uma função que irá iniciar o efeito de dano:

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

Na função update, vamos verificar quanto tempo ainda resta no cronômetro, reduzi-lo a cada piscada e chamar um reset quando o tempo acabar:

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

É isso — agora, quando StartHitEffect é chamado, o sprite piscará branco por um momento e depois voltará para suas cores anteriores.

Resumo

Isto marca o fim do tutorial! Espero que você tenha achado o método aceitável e o shader útil. É realmente simples, mas ele funciona muito bem para sprites de pixel art que não usam muitas cores.

O método precisa ser alterado um pouco, se quisermos trocar grupos inteiros de cores ao mesmo tempo, isso definitivamente exigiria um shader mais complicado e caro. No meu próprio jogo, porém, estou usando muito poucas cores, então esta técnica se encaixa perfeitamente.

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.