OK: I'm trying to make enemies in Witcheye flash red. When you hit enemies in video games, they're supposed to flash red, by god! This trope dates back to the days of indexed palettes, like those on the NES or SNES. Remember how I said that NES sprites were drawn with four-color palettes? So if you wanted to make the sprite flash red, you didn't have to change the sprite; you could just change that object's palette to red for a frame, and then change it back.
But today, it's not as simple. Sprites don't have indexed palettes--they just have the colors they have.
Unity, the engine I'm using to build this game, provides a color-tinting control you can use on your graphics. For the past two years of development, that's what I've been using for hit-flashes. The code is something like this:
if (rend.color == Color.red) {
rend.color = Color.yellow;
} else {
rend.color = Color.red;
}
Every frame, this changes the tint of the enemy's sprite ("rend" for "sprite renderer") from yellow to red or back. Should do the trick, right?
Unity, the engine I'm using to build this game, provides a color-tinting control you can use on your graphics. For the past two years of development, that's what I've been using for hit-flashes. The code is something like this:
if (rend.color == Color.red) {
rend.color = Color.yellow;
} else {
rend.color = Color.red;
}
Every frame, this changes the tint of the enemy's sprite ("rend" for "sprite renderer") from yellow to red or back. Should do the trick, right?
Here's the problem: the color tint doesn't always look so great. That's because it's multiplicative. Every color in the drawing of this big manticore is stored as 4 values:
r: a value of 0 to 1 indicating how red it is
g: a value of 0 to 1 indicating how green it is
b: a value of 0 to 1 indicating how blue it is
a: a value of 0 to 1 indicating how solid it is--so, 0 is completely transparent
You can make any color out of the combination of red, green, and blue.
When I tint the sprite red, each pixel of it has each of its foundational values multiplied with the foundational value of the red tint.
Let's say I'm tinting a white pixel:
WHITE PIXEL
r = 1
g = 1
b = 1
a = 1
multiplied by
RED TINT
r = 1
g = 0
b = 0
a = 1
so, we end up with the
RESULT
r = 1
g = 0
b = 0
a = 1
...which is pretty much what we want. The white pixel turns red.
Here's where we run into trouble, though. What happens if the pixel (like most of this manticore) is green?
GREEN PIXEL
r = 0
g = 1
b = 0
a = 1
multiplied by
RED TINT
r = 1
g = 0
b = 0
a = 1
gets us
RESULT
r = 0
g = 0
b = 0
a = 1
Well, it's not red. Actually, it's black. So when we flash between yellow and red tints, we're actually flashing between a kind of yellowish green and a mostly black.
r: a value of 0 to 1 indicating how red it is
g: a value of 0 to 1 indicating how green it is
b: a value of 0 to 1 indicating how blue it is
a: a value of 0 to 1 indicating how solid it is--so, 0 is completely transparent
You can make any color out of the combination of red, green, and blue.
When I tint the sprite red, each pixel of it has each of its foundational values multiplied with the foundational value of the red tint.
Let's say I'm tinting a white pixel:
WHITE PIXEL
r = 1
g = 1
b = 1
a = 1
multiplied by
RED TINT
r = 1
g = 0
b = 0
a = 1
so, we end up with the
RESULT
r = 1
g = 0
b = 0
a = 1
...which is pretty much what we want. The white pixel turns red.
Here's where we run into trouble, though. What happens if the pixel (like most of this manticore) is green?
GREEN PIXEL
r = 0
g = 1
b = 0
a = 1
multiplied by
RED TINT
r = 1
g = 0
b = 0
a = 1
gets us
RESULT
r = 0
g = 0
b = 0
a = 1
Well, it's not red. Actually, it's black. So when we flash between yellow and red tints, we're actually flashing between a kind of yellowish green and a mostly black.
What we need is not to tint the sprite, but to actively recolor it. To do that, we have to dig into Unity's default sprite shader.
Remember how I said I was using these undesirable tints for two years? That's how little I wanted to mess with the shader.
Nevertheless, I decided I had to bear down and do it, and after a day or so of staring at mystery code, I figured it out.
So here's what you see if you open up Unity's default sprite shader:
Remember how I said I was using these undesirable tints for two years? That's how little I wanted to mess with the shader.
Nevertheless, I decided I had to bear down and do it, and after a day or so of staring at mystery code, I figured it out.
So here's what you see if you open up Unity's default sprite shader:
If you've done any coding before, you might notice there's nothing here that looks like it's drawing a pixel anywhere. (I condensed the properties and tags sections, because they're not going to help us at the moment.) It's impossible to modify this, because there's not really anything here.
The trick is, the "#pragma" directive basically just means "Take a chunk of code from somewhere else--a simple one that we're going to use in a bunch of different shaders--and just run with that." So the actual wheel-to-road contact isn't going on here. Specifically, "#pragma fragment SpriteFrag" is calling in a generic function called "SpriteFrag" that's stored somewhere else. If we want to modify anything, we have to modify that.
Poking around a bit, it turns out this code is in a file called UnitySprites.cginc. Here it is:
The trick is, the "#pragma" directive basically just means "Take a chunk of code from somewhere else--a simple one that we're going to use in a bunch of different shaders--and just run with that." So the actual wheel-to-road contact isn't going on here. Specifically, "#pragma fragment SpriteFrag" is calling in a generic function called "SpriteFrag" that's stored somewhere else. If we want to modify anything, we have to modify that.
Poking around a bit, it turns out this code is in a file called UnitySprites.cginc. Here it is:
Now that looks more like it. We can actually take this code and put it back into Sprites-Default.shader, replacing the #pragma directive, and get the exact same results. (By the way, if you're going to poke around with the shader, I'd suggest copying the file and changing the name--use Sprites-DefaultModified or something like that.)
So this code is relatively approachable, right? The meat of it is really simple:
1. It pulls the color from the sprite coordinates it's looking at (SampleSpriteTexture).
2. It multiplies by the tint color (* IN.color).
3. It multiplies the three "chroma" channels (red, green, blue) by the alpha (transparency) channel, so that transparent pixels (ones where a is 0) come out as properly transparent.
We want our sprite to flash red, even if it's blue; we want the brightness of each pixel of the red version to be determined by the brightness of each pixel of the untinted version. So a bright blue pixel should come out as bright red, right? How are we going to do that?
Well, first we need to figure out the brightness of each pixel. Essentially, what would the sprite look like in grayscale--rendered just from black to white? We can do that by averaging the r, g, and b values, then reassigning each of them to be the average. Here's code that does that:
So this code is relatively approachable, right? The meat of it is really simple:
1. It pulls the color from the sprite coordinates it's looking at (SampleSpriteTexture).
2. It multiplies by the tint color (* IN.color).
3. It multiplies the three "chroma" channels (red, green, blue) by the alpha (transparency) channel, so that transparent pixels (ones where a is 0) come out as properly transparent.
We want our sprite to flash red, even if it's blue; we want the brightness of each pixel of the red version to be determined by the brightness of each pixel of the untinted version. So a bright blue pixel should come out as bright red, right? How are we going to do that?
Well, first we need to figure out the brightness of each pixel. Essentially, what would the sprite look like in grayscale--rendered just from black to white? We can do that by averaging the r, g, and b values, then reassigning each of them to be the average. Here's code that does that:
The line that might confuse you here is
float value = dot (c.rgb, float3(0.3, 0.59, 0.11));
Why are we multiplying r, g, and b by different amounts? The answer is that the human eye is most sensitive to green, so we give it more weight in our brightness calculation. You could write this line to weigh everything equally--0.33, 0.33, 0.33--but it might come out looking a little flat.
So what we should get here is a grayscale version of the sprite. And indeed:
float value = dot (c.rgb, float3(0.3, 0.59, 0.11));
Why are we multiplying r, g, and b by different amounts? The answer is that the human eye is most sensitive to green, so we give it more weight in our brightness calculation. You could write this line to weigh everything equally--0.33, 0.33, 0.33--but it might come out looking a little flat.
So what we should get here is a grayscale version of the sprite. And indeed:
Now all that remains is to go back to our regular C# code and use the same tint function (which appears here in the shader as "c.rgb *= IN.color;") to tint the sprite red and yellow on alternate frames, right?
Hmm... actually, that doesn't look so great, does it? Everything looks kind of blah--kind of like it didn't quite make it back out of grayscale.
Can we come up with something more vivid? What if we take the brightness value, and use it to pick a color on a gradient from, say, black to red to yellow? That way when our recolor is applied, the highlights of the sprite still look bright and punchy; the overall effect is more incandescent.
Can we come up with something more vivid? What if we take the brightness value, and use it to pick a color on a gradient from, say, black to red to yellow? That way when our recolor is applied, the highlights of the sprite still look bright and punchy; the overall effect is more incandescent.
This code says, OK: if the brightness is less than half (value < 0.5), multiply it by two so that we get a range from 0 to 1, then use that number to pick a color between black and red. ("Lerp" means "linear interpretation"--the third value of the function is a number between 0 and 1 that indicates where the result should land between the first two values.) If the brightness is more than half (value > 0.5), subtract 0.5 and then multiply by two so that we get a range from 0 to 1 again, then use THAT number to pick a color between red and yellow. (You get yellow from maxing out your red and green channels--1.0, 1.0, 0.0 is yellow. Doesn't make much sense with paint, but that's how light works.)
Still with me? Here's how that looks:
Still with me? Here's how that looks:
Hey, not bad!
Now, the problem is, we can't turn this effect off, or modify the values--it's pretty much hard-coded into the shader. What we need to do is pass the shader some values saying when we want to recolor the sprite, and with what hues.
To do that, we reopen the properties block at the top of the file and add three new external properties: RecolorColorBlack, RecolorColorGray, and RecolorColorWhite. Those represent the three points on our value scale.
Now, the problem is, we can't turn this effect off, or modify the values--it's pretty much hard-coded into the shader. What we need to do is pass the shader some values saying when we want to recolor the sprite, and with what hues.
To do that, we reopen the properties block at the top of the file and add three new external properties: RecolorColorBlack, RecolorColorGray, and RecolorColorWhite. Those represent the three points on our value scale.
We use the alpha channel of RecolorColorBlack to determine whether we're going to recolor the sprite at all--if it's set to 0, we never even do the math here. If it's greater than that, we blend the recolored output with the base color for a subtler effect. That's what the line "c.rgb = lerp(c.rgb, gradientColor.rgb, _RecolorColorBlack.a);" does. If RecolorColorBlack.a is set to a full value of 1, we're fully recoloring; if it's set to 0.5, we're halfway between.
(Now, probably the more professional thing to do would be to pass this shader a separate value indicating how much of the recolored version to use in the final output... but at this point I feel like we're in deep enough!)
So here's the meat of the final shader:
(Now, probably the more professional thing to do would be to pass this shader a separate value indicating how much of the recolored version to use in the final output... but at this point I feel like we're in deep enough!)
So here's the meat of the final shader:
Now, a final challenge: how do we access these properties within our regular old Unity code? It's not so bad. Here's the C# code to do that:
With this function set up, we can change our enemy code to make sprites flash between their normal look and a black-red-yellow gradient:
Or between a black-red-yellow gradient and a black-blue-cyan gradient:
Or between a black-red-yellow gradient and an inverted yellow-red-black gradient:
Or between a black-red-yellow gradient and a purple-pink-white gradient:
Or, as I eventually did, between a black-red-yellow gradient and a black-red-yellow gradient blended halfway with the underlying color of the sprite itself, for a relatively subtle hit-flash effect.
Here I would conclude with some deep thoughts about the power of shaders, the long journey of computer graphics history, etc, but, well, we've all been through a lot. I had fun finally learning at least a little about the wide world of shaders, and I hope this has been at least a little interesting/helpful. Thanks for reading!