Look At All The Pretty Colors
Before I start with any of the details of this shader, I want to call out that I knew nothing about shader programming until a few months ago
and highly recommend the 2D Shader Development books for learning the basics of
shaders. The book series is geared towards Unity and 2d but the concepts of the first book should carry forward to 3d.
The second book in the series is all about lighting so I'm not sure how well that translates to 3d.
Oh! And one more thing. All finished code is linked at the bottom of this page. Just scroll to the bottom if you don't want to see the process
of making this shader.
With that said, let's get started!
For my game Doopel Ponger I wanted to have a color changing affect where when a bullet hits a wall, the color of that bullet
will splash through the wall like a shockwave. It just so happened to be I was reading the second book in the 2d shaders book series
about lighting when I realized everything in the book is exactly the information I need to get this affect. The only difference is that
the book talks about making lights that affect every object in a scene where I wanted to make a light that affects only a single
object. There can be any number of bullets hitting the objects so it would need to support multiple "lights" affecting the color of the objects.
To get this affect I wanted I broke it down into several steps:
- Get a basic shader to draw a white texture
- Get a circular light to change the color of a texture
- Be able to adjust the circular light position, radius, and gradient via script
- Support more than one circular lights in the shader
- Add color blending between the lights if they overlap
Basic Shader
For this section's explanation I'm just going to point you to the
first book in the 2d shaders series. The author had a good write up for creating a basic shader that reads a texture and
displays it on the screen, so I won't repeat their work in a worse way and just show you the code. I also just picked a white
texture to display so that I could tell what was going on more easily when changing colors around.
Shader "Unlit/ColorChangingShader"
{
Properties
{
_MainTex("Texture", 2D) = "white" {}
}
SubShader
{
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
v2f vert(appdata input)
{
v2f output;
output.vertex = UnityObjectToClipPos(input.vertex);
output.uv = input.uv;
return output;
}
half4 frag(v2f input): SV_Target
{
return tex2D(_MainTex, input.uv);
}
ENDCG
}
}
}
There's nothing fancy on screen, but I wanted to show what it should look like once the shader compiles.
Circular Light
The next step is to actually add a light to the shader. There are four inputs to add to the shader: world position, color, radius, and
gradient percent. The way I use gradient percent isn't exactly something I'm happy with but it seems okay in the final result so I don't
think I'm going to change it. The gradient percent is used to control when the smoothing should happen. So if you set the gradient to .5
then if the pixel is between 0% and 50% of the light radius, then the resulting color will be what the light color is. If the pixel is between 50%
and 100% of the light radius, then the resulting color will be lerp'ed between the light color and white. If you're wondering why I lerp to white
just hold your question to the fragment shader section, I'll explain the blending logic there.
Properties
{
_WorldPosition("World Position", Vector) = (0,0,0,0)
_Color("Light Color", Color) = (1,0,0,1)
_ColorDistance("Color Distance", float) = 1
_PercentGradient("Percent Gradient", float) = .5
_MainTex("Texture", 2D) = "white" {}
}
Up next I needed to translate the object's position from object space to world space. This allows me to calculate the distance from the
center of the light and decide what color the pixel needs to be. So in the vertex shader I used Unity's built in object to world matrix
and added a new property in the v2f struct to hold the vertex's world position. The vertex shader should now look like:
v2f vert(appdata input)
{
v2f output;
output.vertex = UnityObjectToClipPos(input.vertex);
output.worldPosition = mul(unity_ObjectToWorld, input.vertex);
output.uv = input.uv;
return output;
}
Moving on to the fragment shader, we have to calculate the distance from the center of the light and then check if the distance is less
than the radius, if so multiply the texture color with the light color. The important thing to call out is that
the default color for multiplication blending is white. The reason for this is because multiplying a number by 1 results in no change.
So multiplying any color by pure white will result in no change. When calculating the gradient I thought that using the smoothstep function
looked better than the linear interpolation. No other reason then that.
half4 frag(v2f input): SV_Target
{
half distanceToLight = distance(input.worldPosition, _WorldPosition);
half4 multiplyColor = half4(1, 1, 1, 1);
if(distanceToLight < _ColorDistance)
{
half gradientStartRadius = _ColorDistance * _PercentGradient;
half gradientDistance = _ColorDistance - gradientStartRadius;
half smoothStepValue = smoothstep(0, 1, (distanceToLight - gradientStartRadius) / gradientDistance);
multiplyColor = lerp(_Color, half4(1, 1, 1, 1), smoothStepValue);
}
return tex2D(_MainTex, input.uv) * multiplyColor;
}
With the light set to world position (2,2), radius of 2, color red, and gradient percent of .4, you can see there's a circle in the top right
of the quad. Easy so far!
Dynamic Light
In order to make this shader look more impressive we need to make it so that all the properties can be controlled by a script.
Similar to the above this part is pretty easy and to make it look "aminated" I just set up a coroutine to change the shader properties.
I'm assuming anyone reading this has basic Unity knowledge so I won't explain the code. However, at the high level, when you
press the space bar the light will spawn and grow to the max light distance. Once the max light distance has been reached
the color of the light will start turning into white. And being a good Unity developer I made sure all these properties can be
controlled in the inspector. You can see how everything should look in the gif below.
using UnityEngine;
using System.Collections;
public class ColorChangingController: MonoBehaviour
{
public Color color;
[Range(0,1)]
public float gradient;
public Vector4 worldPosition;
public float maxLightDistance;
public float timeToMaxLightDistance;
public float timeToColorDisolve;
private Material material;
public void Awake()
{
MeshRenderer meshRenderer = GetComponent();
material = meshRenderer.material;
}
public void Update()
{
if(Input.GetKeyDown(KeyCode.Space))
{
material.SetColor("_Color", color);
material.SetFloat("_PercentGradient", gradient);
material.SetVector("_WorldPosition", worldPosition);
material.SetFloat("_ColorDistance", 0);
StartCoroutine(Flash());
}
}
private IEnumerator Flash()
{
float distance = 0;
float distanceChangeRate = maxLightDistance / timeToMaxLightDistance;
float colorTimer = 0;
while(distance < maxLightDistance)
{
distance += distanceChangeRate * Time.deltaTime;
material.SetFloat("_ColorDistance", distance);
yield return null;
}
while(colorTimer < timeToColorDisolve)
{
distance += distanceChangeRate * Time.deltaTime;
colorTimer += Time.deltaTime;
float colorRatio = colorTimer / timeToColorDisolve;
material.SetFloat("_ColorDistance", distance);
material.SetColor("_Color", Color.Lerp(color, Color.white, colorRatio));
yield return null;
}
}
}
More Than One Light
To get multiple lights to affect the shader I saw two approaches that can be summarized by using an array of structures vs a structure of
arrays (kind of). For the array of structures I could use Unity's ComputeBuffer to send all the data necessary at once. With the structure
of arrays approach I would have an array for the four different light properties (color, location, radius, and gradient) and set the array
values when necessary. I didn't do any kind of performance analysis to decide which to use, I only looked at what would be the cleanest code.
So I went with the ComputeBuffer approach as I thought setting particular elements of an array could get messy. With that said, there are
device limitations for using the ComputeBuffer as older versions of Direct3D and OpenGL don't support them. Maybe in the future I'll switch?
The first step in setting up the ComputeBuffer is to define the data that will be passed from C# to Cg. Currently this is the four properties
in the shader: color, radius, position, and gradient. So we can create a struct with these properties in C# and then again in Cg. It is
EXTREMELY important to note that the order of the properties MUST MATCH so that the data gets copied correctly. First is the C# struct
then the Cg struct. I created a constructor in C# only for convenience when creating a new light.
private struct ShaderLightData
{
public Vector2 worldPosition;
public Vector4 color;
public float distance;
public float gradient;
public ShaderLightData(Vector2 worldPosition, Vector4 color, float distance, float gradient)
{
this.worldPosition = worldPosition;
this.color = color;
this.distance = distance;
this.gradient = gradient;
}
}
struct lightData
{
float2 worldPosition;
float4 lightColor;
float colorDistance;
float percentGradient;
};
Up next you have to initialize the ComputeBuffer to be a fixed size. This means that you cannot have an infinite number of lights to affect
the color of the object :( But if you know an absolute max, the players will never know the difference. While testing I only set this value to be two.
The other important thing you need to know when creating a ComputeBuffer is that you need to tell it how many bytes the structure is. In this case
there are 8 floats (Vector4 contains 4 floats and Vector2 contains 2 floats) so you can do sizeof(float) * 8 to get the size. The new awake
method in the C# script should now look like the below.
public void Awake()
{
int maxNumberOfLights = 2;
lightsBuffer = new ComputeBuffer(maxNumberOfLights, sizeof(float) * 8, ComputeBufferType.Default);
shaderLightData = new ShaderLightData[maxNumberOfLights];
for(int i = 0; i < maxNumberOfLights; i++)
{
shaderLightData[i] = new ShaderLightData(new Vector2(), Color.white, 0, .5f);
}
MeshRenderer meshRenderer = GetComponent();
material = meshRenderer.material;
material.SetInt("_LightDataSize", maxNumberOfLights);
}
The next big change is how the shader gets the new information. Since the ComputeBuffer works with arrays instead of single items I decided
to use the LateUpdate method to send the shaderLightData to the shader. I didn't do any research to see if this is the recommend way, but
I haven't seen any weird behavior, so I've kept it :) I also modified the coroutine to take in an index so that it will update the index
of the shaderLightData and let the LateUpdate method send the information to the GPU. The new coroutine and LateUpdate now look like this.
public void LateUpdate()
{
lightsBuffer.SetData(shaderLightData);
material.SetBuffer("_LightData", lightsBuffer);
}
private IEnumerator Flash(int index, InspectorLightData inspectorData)
{
shaderLightData[index].distance = 0;
shaderLightData[index].color = inspectorData.color;
shaderLightData[index].gradient = inspectorData.gradient;
shaderLightData[index].worldPosition = inspectorData.worldPosition;
float distance = 0;
float distanceChangeRate = inspectorData.maxLightDistance / inspectorData.timeToMaxLightDistance;
float colorTimer = 0;
while(distance < inspectorData.maxLightDistance)
{
distance += distanceChangeRate * Time.deltaTime;
shaderLightData[index].distance = distance;
yield return null;
}
while(colorTimer < inspectorData.timeToColorDisolve)
{
distance += distanceChangeRate * Time.deltaTime;
colorTimer += Time.deltaTime;
float colorRatio = colorTimer / inspectorData.timeToColorDisolve;
shaderLightData[index].distance = distance;
shaderLightData[index].color = Color.Lerp(inspectorData.color, Color.white, colorRatio);
yield return null;
}
}
If you look really, really closely at what we currently have you can see a bug. The shader doesn't blend colors so the blue writes over
the orange. We can't have this, so let's move on to the last section.
Blending Colors
To blend the colors I decided to use a weighted average in the RGB spectrum. I did a little bit of Googling on this topic and it seems that
averaging colors is best done when colors are in the hue, saturation, and brightness representation. However, I thought things looked good
enough in the RGB representation so I just stuck with that since the colors were already in that format.
To determine the weight when averaging, I use the distance from the center of the light. So the farther away from the center of the circle
the less it is going to influence in the color to multiply with the original texture. And of course the max weight is set via the
ComputeBuffer so the lightData struct needs to have the maxWeight field added. So now the fragment shader looks like the following.
half4 frag(v2f input): SV_Target
{
float4 averageColor = float4(0, 0, 0, 0);
float totalWeight = 0;
for(int i = 0; i < _LightDataSize; i++)
{
lightData data = _LightData[i];
half distanceToLight = distance(input.worldPosition, data.worldPosition);
if(distanceToLight < data.colorDistance)
{
half gradientStartRadius = data.colorDistance * data.percentGradient;
half gradientDistance = data.colorDistance - gradientStartRadius;
half smoothStepValue = smoothstep(0, 1, (distanceToLight - gradientStartRadius) / gradientDistance);
float weight = data.maxWeight * smoothstep(0.0, data.maxWeight, 1 - (distanceToLight / data.colorDistance));
half4 pixelColor = lerp(data.lightColor, half4(1, 1, 1, 1), smoothStepValue);
totalWeight += weight;
pixelColor *= weight;
averageColor += pixelColor;
}
}
if(totalWeight != 0)
averageColor /= totalWeight;
else
averageColor = float4(1, 1, 1, 1);
return tex2D(_MainTex, input.uv) * averageColor;
}
For the C# side of things, the maxWeight attribute needs to be added to the ShaderLightData struct and the size of the struct needs to
increase by one when constructing the ComputeBuffer. The only other change related to the weight done in C# is that when the color
fades to white, the overall weight of the light decreases to zero. This is done so that if a new light spawns under the fading light,
the newer light will shine brighter than the fading one. So now the color fading loop of the coroutine should now look like the following.
while(colorTimer < inspectorData.timeToColorDisolve)
{
distance += distanceChangeRate * Time.deltaTime;
colorTimer += Time.deltaTime;
float colorRatio = colorTimer / inspectorData.timeToColorDisolve;
shaderLightData[index].distance = distance;
shaderLightData[index].color = Color.Lerp(inspectorData.color, Color.white, colorRatio);
shaderLightData[index].maxWeight = Mathf.SmoothStep(UPPER_WEIGHT, LOWER_WEIGHT, colorRatio);
yield return null;
}
Now doesn't that look pretty cool? I think so, but maybe I'm biased because I made it :) I will admit there's a bit more work to get to the
rainbow gif but it's all standard Unity stuff from here, like creating a collision listener and rigidbodies. But the C# and shader code
can all remain the same to get that working.
As promised, the final C# script and shader code can be found below. But first I want to say thank you for reading all the way to the end
and that I developed this shader while streaming on twitch. If you're interested in checking out some more game development
head on over to twitch.tv/toasterfuel.
At the time of writing this I stream every weekday morning starting at 7am PDT
Final C# Code
using UnityEngine;
using System.Collections;
public class ColorChangingController: MonoBehaviour
{
private static readonly float UPPER_WEIGHT = 10f;
private static readonly float LOWER_WEIGHT = 0f;
[System.Serializable]
public struct InspectorLightData
{
public Color color;
[Range(0,1)]
public float gradient;
public Vector2 worldPosition;
public float maxLightDistance;
public float timeToMaxLightDistance;
public float timeToColorDisolve;
}
private struct ShaderLightData
{
public Vector2 worldPosition;
public Vector4 color;
public float distance;
public float gradient;
public float maxWeight;
public ShaderLightData(Vector2 worldPosition, Vector4 color, float distance, float gradient, float maxWeight)
{
this.worldPosition = worldPosition;
this.color = color;
this.distance = distance;
this.gradient = gradient;
this.maxWeight = maxWeight;
}
}
public InspectorLightData[] inspectorLightData;
private Material material;
private ComputeBuffer lightsBuffer;
private ShaderLightData[] shaderLightData;
public void Awake()
{
int maxNumberOfLights = 2;
lightsBuffer = new ComputeBuffer(maxNumberOfLights, sizeof(float) * 9, ComputeBufferType.Default);
shaderLightData = new ShaderLightData[maxNumberOfLights];
for (int i = 0; i < maxNumberOfLights; i++)
{
shaderLightData[i] = new ShaderLightData(new Vector2(), Color.white, 0, .5f, LOWER_WEIGHT);
}
MeshRenderer meshRenderer = GetComponent();
material = meshRenderer.material;
material.SetInt("_LightDataSize", maxNumberOfLights);
}
public void Update()
{
if(Input.GetKeyDown(KeyCode.Space))
{
for(int i = 0; i < 2; i++)
{
StartCoroutine(Flash(i, inspectorLightData[i]));
}
}
}
public void LateUpdate()
{
lightsBuffer.SetData(shaderLightData);
material.SetBuffer("_LightData", lightsBuffer);
}
private IEnumerator Flash(int index, InspectorLightData inspectorData)
{
shaderLightData[index].distance = 0;
shaderLightData[index].color = inspectorData.color;
shaderLightData[index].gradient = inspectorData.gradient;
shaderLightData[index].worldPosition = inspectorData.worldPosition;
shaderLightData[index].maxWeight = UPPER_WEIGHT;
float distance = 0;
float distanceChangeRate = inspectorData.maxLightDistance / inspectorData.timeToMaxLightDistance;
float colorTimer = 0;
while(distance < inspectorData.maxLightDistance)
{
distance += distanceChangeRate * Time.deltaTime;
shaderLightData[index].distance = distance;
yield return null;
}
while(colorTimer < inspectorData.timeToColorDisolve)
{
distance += distanceChangeRate * Time.deltaTime;
colorTimer += Time.deltaTime;
float colorRatio = colorTimer / inspectorData.timeToColorDisolve;
shaderLightData[index].distance = distance;
shaderLightData[index].color = Color.Lerp(inspectorData.color, Color.white, colorRatio);
shaderLightData[index].maxWeight = Mathf.SmoothStep(UPPER_WEIGHT, LOWER_WEIGHT, colorRatio);
yield return null;
}
}
}
Final Cg Code
Shader "Unlit/ColorChangingShader"
{
Properties
{
_MainTex("Texture", 2D) = "white" {}
}
SubShader
{
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float2 worldPosition: TEXCOORD1;
};
struct lightData
{
float2 worldPosition;
float4 lightColor;
float colorDistance;
float percentGradient;
float maxWeight;
};
int _LightDataSize;
sampler2D _MainTex;
uniform StructuredBuffer _LightData;
v2f vert(appdata input)
{
v2f output;
output.vertex = UnityObjectToClipPos(input.vertex);
output.worldPosition = mul(unity_ObjectToWorld, input.vertex);
output.uv = input.uv;
return output;
}
half4 frag(v2f input): SV_Target
{
float4 averageColor = float4(0, 0, 0, 0);
float totalWeight = 0;
for(int i = 0; i < _LightDataSize; i++)
{
lightData data = _LightData[i];
half distanceToLight = distance(input.worldPosition, data.worldPosition);
if(distanceToLight < data.colorDistance)
{
half gradientStartRadius = data.colorDistance * data.percentGradient;
half gradientDistance = data.colorDistance - gradientStartRadius;
half smoothStepValue = smoothstep(0, 1, (distanceToLight - gradientStartRadius) / gradientDistance);
float weight = data.maxWeight * smoothstep(0.0, data.maxWeight, 1 - (distanceToLight / data.colorDistance));
half4 pixelColor = lerp(data.lightColor, half4(1, 1, 1, 1), smoothStepValue);
totalWeight += weight;
pixelColor *= weight;
averageColor += pixelColor;
}
}
if(totalWeight != 0)
averageColor /= totalWeight;
else
averageColor = float4(1, 1, 1, 1);
return tex2D(_MainTex, input.uv) * averageColor;
}
ENDCG
}
}
}