Devlog #001: Recoloring Sprites in Unity

Bagoum
Bagoum
Nov 4 · 7 min read

A few months ago, I wrote a somewhat disappointed review of Remnant: From the Ashes, in which I suggested that the goal of “Dark Souls with guns” might be best served by 2D games with combat loops based on the danmaku (bullet hell) genre. Given the ever-increasing proliferation of Soulslike games in disparate genres, I expected to eventually find a game doing just that. But I didn’t. So I decided to make it myself.

Progress has been fairly smooth since I started, and I’ve already released two scripts on Bulletforge, one of the sites that host Touhou fangames. This post series will serve as a devlog for some of the cool things that I find on my Game Development Journey (and there are a lot of cool things). Most of them will be somewhat technical. This one is fairly technical and also quite specific to Unity — the base engine I’m using.

All the resources for this post can be found on this Github repo licensed under CC0 (effectively public domain).

What are the Options?

There are three times at which we can recolor sprites:

  • Before running the game (ie. in Photoshop),
  • Before providing a texture to the shader,
  • Within the shader.

The first option seems to be the most obvious at first glance. In reality, it’s the worst option: it bloats our resources and also requires much more painful iteration cycles. In developping anything, you should always keep in mind the question: how long does it take to fix everything else if I want to change this one isolated part? Ideally the answer should be “no time”. If we keep separate sprites for each different coloring of a basic object, then every time we change either the coloring or the object shape, we need to remake a bunch of sprites.

The third option is overall the best — it minimizes texture memory usage and allows wacky hijinks like dynamic color palette tweening. In fact, sometimes I really feel like I should have used it. However, in order to work properly with standardized color palettes and arbitrary color maps, it requires some funky intermediate steps like exporting a sampling texture from sampling a function, and I still only barely understand what that means. I think Disgaea uses this method, as I don’t see any other way for its extremely flexible sprite recoloring tools to work.

One possible implementation of this is to export the player-inputted colors into small gradient textures with pre-defined dark/light extrapolation, concatenate them, and provide it as a sampling texture for a sprite which is defined in partitioned grayscale.

Since the first method is bad and I’m not versed enough in the arts of shader magic to do the third, I opted for the second method. In this method, we use color palette objects and recoloring objects to perform recoloring sometime before the texture is requested.

Part 1. Palettes

First, we want to provide a universal source of color information for our recolorings. What exactly does blue look like? How about a dark shade of blue, or a light shade? For this, we can create a simple ScriptableObject that describes some shades of a color.

[CreateAssetMenu(menuName = "Colors/Palette")]
public class Palette : ScriptableObject {
public enum Shade {
WHITE,
HIGHLIGHT,
LIGHT,
PURE,
DARK,
OUTLINE,
BLACK
}
public string colorName;
public Color highlight;
public Color light;
public Color pure;
public Color dark;
public Color outline;
private static readonly Color BLACK = Color.black;
private static readonly Color WHITE = Color.white;


public Color GetColor(Shade shade) {
if (shade == Shade.WHITE) {
return WHITE;
} else if (shade == Shade.HIGHLIGHT) {
return highlight;
} else if (shade == Shade.LIGHT) {
return light;
} else if (shade == Shade.PURE) {
return pure;
} else if (shade == Shade.DARK) {
return dark;
} else if (shade == Shade.OUTLINE) {
return outline;
}
return BLACK;
}
}

The inspector window for this is fairly inoffensive:

Part 2. ColorMap

Now we need something to actually recolor our textures. To provide a useful example, let’s say we want to recolor textures on a gradient defined by two endpoints and a halfway point, where these points are colors from our palettes. In the essence of abstraction, we’ll first prepare all the code except for the actual recoloring:

public abstract class ColorMap : ScriptableObject {
protected virtual void PrepareColors() { }
protected const byte zero = 0;
public Sprite Recolor(Sprite baseSprite) {
PrepareColors();
Texture2D tex = Instantiate(baseSprite.texture);
NativeArray<Color32> pixels_n = tex.GetRawTextureData<Color32>();
int len = pixels_n.Length;
unsafe {
Color32* pixels = (Color32*)pixels_n.GetUnsafePtr();
Map(pixels, len);
}
tex.Apply();
Vector2 pivot = baseSprite.pivot;
pivot.x /= baseSprite.rect.width;
pivot.y /= baseSprite.rect.height;
var s = Sprite.Create(tex, baseSprite.rect, pivot, baseSprite.pixelsPerUnit);
return s;
}
protected abstract unsafe void Map(Color32* pixels, int len);
}

(Note: I use pointers here because NativeArray access has some weird bounds-checks that severely slow down execution. However, you can just as well use NativeArray instead, as we’ll only be doing indexing.)

The key pattern to observe for recoloring is that we can get texture data, rewrite it, and then create a new sprite with the new texture data. There are a few places here where you might get tripped up:

  • You need to call tex.Apply after editing a texture’s raw data,
  • You need to call GetRawTextureData and not GetPixels, which will work but also allocate half your bank account,
  • Sprite.pivot is in pixels, but when creating a new sprite you have to provide it as normalized to [(0,0), (1,1)],
  • In Sprite Import settings, make sure Read/Write Enabled is on, and you probably want the format to be RGBA 32,
  • You may need to modify other options like extrusion or mesh type in the Sprite constructor (I set my mesh type to FullRect for shader effects).

From here, the mapping code is comparatively simple. I separated it into two classes: an abstract class which performs the logic of using the grayscale value of the texture (simply pixel.r, assuming that the image is already in grayscale) to get a value from a gradient of three colors, and a concrete class which sets those three colors from a palette.

public abstract class ThreeColorGradientMap : ColorMap {
protected Color32 mBlack;
protected Color32 mGray;
protected Color32 mWhite;

protected override unsafe void Map(Color32* pixels, int len) {
for (int ii = 0; ii < len; ++ii) {
Color32 pixel = pixels[ii];
float value = pixel.r / 255f;
if (value > 0.5f) {
value = value * 2 - 1f;
//Lerp from gray-color to white-color
pixel.r = (byte)(mGray.r + value * (mWhite.r - mGray.r));
pixel.g = (byte)(mGray.g + value * (mWhite.g - mGray.g));
pixel.b = (byte)(mGray.b + value * (mWhite.b - mGray.b));
} else {
value = value * 2;
//Lerp from black-color to gray-color
pixel.r = (byte)(mBlack.r + value * (mGray.r - mBlack.r));
pixel.g = (byte)(mBlack.g + value * (mGray.g - mBlack.g));
pixel.b = (byte)(mBlack.b + value * (mGray.b - mBlack.b));
}
pixels[ii] = pixel;
}
}
}

[CreateAssetMenu(menuName = "Colors/ThreeColorGradient")]
public class PaletteThreeColorGradientMap : ThreeColorGradientMap {
public Palette mapToBlackBase;
public Palette.Shade mapToBlackShade;
public Palette mapToGrayBase;
public Palette.Shade mapToGrayShade;
public Palette mapToWhiteBase;
public Palette.Shade mapToWhiteShade;

protected override void PrepareColors() {
mBlack = mapToBlackBase.GetColor(mapToBlackShade);
mGray = mapToGrayBase.GetColor(mapToGrayShade);
mWhite = mapToWhiteBase.GetColor(mapToWhiteShade);
}
}

Note that this code uses Color32, while the Palette code uses Color. This is because Color32 is faster than Color, so we want to (implicitly) convert to Color32 before actually doing recoloring.

You might observe that the design of this code is somewhat bad: instead of abstracting only the actual color mapping, we’re abstracting the mapping of the entire array. We’re also manually rewriting Color.Lerp-- twice!-- we could at least have the good sense to put it in some static function, right? Ultimately, these transgressions are for the sake of optimization. Imagine the perfectly reasonable case where we want to recolor a 1000x1000 image. If we make a separate function for per-pixel mapping or a separate function for color lerping, that's one million function calls. On the other hand, if we make a separate function only for the task of mapping the entire array, that's one function call. Function calls have overhead which is normally irrelevant, but which will start causing problems if you're making millions of them. This would look a lot prettier if C# had preprocessor macros, but instead we have to inline the function every time. It almost makes you feel as if you're forcefully running GPU code on a CPU...

3. Actually Recoloring Things

This part is easy, but in practice will depend significantly on the rest of your project architecture. In danmaku, it’s not feasible to treat projectiles as GameObjects; you instead have to treat them as code abstractions and render them directly to screen with Graphics.DrawMeshInstancedIndirect (this is probably the most magical part of my code!). So recolored sprites get converted into meshes and get attached to "recolored" materials, which are stored in configuration structs on linked lists. But that's for later. In the simplest case, all you have to do is reassign SpriteRenderer.sprite.

public class RecolorMe : MonoBehaviour {
public ColorMap recolorMap;
private void Start() {
SpriteRenderer sr = GetComponent<SpriteRenderer>();
Sprite s = sr.sprite;
Sprite recolored_s = recolorMap.Recolor(s);
sr.sprite = recolored_s;
}
}

I used this script with a ColorMap that sets black to blue, gray to red, and white to dark red, and applied it to a questionable circle. You can see the results below.

Conclusion

In my project, I have about fifteen (monochromatic) color palettes, and exactly four mapping objects. It turns out that in the case of danmaku games, the way you assign palettes to mapping objects is exactly the same for almost all objects. The majority of my sprites are recolored in a two-color mapping, which has three patterns identical for all palettes: Pure->Highlight, Pure->Outline, and Dark->Light. This means that I can instantiate a mapping object at runtime, fill in standardized values from each palette, and then get the recoloring. As a result, all 40+ variations on a single sprite are defined by one grayscale image, fifteen palette objects, and a few standardized lines of code.

Basically, always be automating.

Again, all the resources for this post can be found on this Github repo licensed under CC0 (effectively public domain).

Bagoum

Written by

Bagoum

Software engineer, epic gamer, and student of Barthes and Skinner.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade