Time ago I made a game with a small team for a game jam. The game's called Color Wars and it’s a very simple 2.5D PvP game where you shoot your opponent, painting them while also painting the scenario.
In this post, I want to give a brief deconstruction of how I built this painting system. Not like an in-depth tutorial, but rather as an overall look at the techniques I used.
I'm not sharing here all the custom shaders/scripts I wrote for the system because it'll be out of the scope of the post, but feel free to contact me for more info about it!
The game is available on itch.io.
Behind all the magic, there are the raw 3D models that form the scenario, and the sprites of our little characters.
The trick here is that I'm masking out the color in the alpha channel. In other words, the whole alpha channel of the screen is be black by default, until I throw in some paint splatters which will turn white some areas of it. Then, an image effect blends between color and gray-scale based on that.
As you can see, I'm using projectors to draw the splatters onto the surfaces and create the alpha mask. Each projector is procedurally instantiated when a bullet (the white dots flying) impacts a surface. Projectors have a box collider, so bullets that impact there don’t create another projector but rather make that one bigger. This way paint-splatters grow and we keep the number of projectors in the scene fairly low.
This is part of the script attached to the bullets particle system:
// This is called by Unity to handle particle collisions
void OnParticleCollision ( GameObject other )
// Access the particle collision data
int length = ps.GetCollisionEvents ( other, cols );
for (var i=0; i!=length; i++)
string tag = cols[i].colliderComponent.tag;
// A surface that can be painted
if ( tag == "Paintable" )
var p = Instantiate ( projector );
// This way
p.transform.position = cols[i].intersection + (cols[i].normal * 3.5f);
p.transform.LookAt ( cols[i].intersection );
// An already created projector
if ( tag == "Painter" )
// Access the projector and its collider
var c = cols[i].colliderComponent.transform;
var p = c.parent.GetComponent<Projector> ();
// Avoid projectors from growing infinetly
if (p.fieldOfView <= 105f)
// The projector is orthogonal, so making the FoV bigger
// makes the paint splatter bigger too
p.fieldOfView += 0.5f;
// What I'm doing here is rotate the projector to make the splatter
// rotate to look more dynamic, but preserve the collider rotation
// to avoid future bullets missing it
var cRot = c.rotation;
p.transform.rotation *= Quaternion.Euler ( -0.3f, 0f, 0f );
c.rotation = cRot;
// Increasing the projector scale doesn't make the projection bigger,
// but it increases the collider size to better match the projection size
if (p.fieldOfView <= 80f)
c.transform.localScale += new Vector3 ( 0.005f, 0.005f, 0f );
As shown above, when bullets impact a surface, a Unity projector is procedurally created in the impact point. These projectors have a custom material and a custom shader. The material texture is just a custom gray-scale texture as you can see below. From these splatters what I wanted is the gray-scale value written into the alpha channel, but I didn’t want them to write to the color channels. So again I grabbed the default Unity Projector shader and modified it a bit to fit these needs.
With all the projectors working I could modify the alpha channel of the screen at will, but this isn’t doing nothing on its own. The last piece in the system is an image effect to make use of the alpha mask.
For this, I just created a default Image Effect Shader inside Unity — then replaced the fragment function with this:
fixed4 frag (v2f i) : SV_Target
fixed4 col = tex2D(_MainTex, i.uv);
// This line generates a Black&White version of the screen
fixed3 bnw = dot(col.rgb, float3(0.3, 0.59, 0.11));
// Switch between B&W and Color based on alpha channel
col.rgb = lerp(bnw, col.rgb, col.a);
Basically, “bwn” here is just a desaturated version of the screen, but it could be anything and it would blend nicely.
Finally just created a Image effect script and attached it to the camera.
LIMITATIONS & FURTHER IMPROVEMENT
Being honest, this is a crappy implementation for this kind of effect, but the JAM was only 4 days long and I was pretty proud of the result we got from this, but from a technical point of view, some heavy downsides are:
ALPHA MASKING MEANS NO ALPHA CHANNEL
Which means no transparency and no support for deferred rendering. This could probably be solved using stencils, command buffers or even Multiple Render Targets.
TOO MUCH CUSTOM SHADERS FOR MY TASTE
I like when things “just work” and having to replace every object’s shader to a custom one it’s not really a practice that I like. Deferred rendering solves this — if you get the whole thing to work in deferred.
THANKS FOR READING
I hope you found some interesting stuff throughout the post, and maybe got some inspiration for your graphics and shading works. If you want to share any thoughts, feedback or want a more specific explanation just leave a comment, send me a mail or contact me through Twitter!