Random Colours Shader

I was investigating the problem of creating randomized colour variation (from a pre-defined palette) in meshes, using nothing more than a shader. The idea was to be able to populate a large area with a repeating mesh, but each version of that mesh would have a different color tint. This had to be done from one material, so that I could take advantage of batching and everything could be done in as few draw calls as possible.

A good example might be a library full of books – where you can use one (or a few) book meshes, repeated everywhere at unique positions, with different rotations and scales, but have each one with a different color. Here’s an example of what we could achieve:

For the purposes of this research, I’m not worrying about implementing varying text or different bindings – for now I just want the color variation.

Why can’t you just randomly assign a colour in the shader?

You can’t. Shaders don’t support a nice ‘random(foo)’ function. We need to find a way to generate an apparent random color from within the shader that doesn’t rely on Time.

What to do?

There are a few approaches we could take, but its probably wise to cement the criteria a bit further, first.

We will not be using physics on these objects – we want to be able to have them batch and be statically lit. 

I also want to use Unity’s standard surface shader for this, as means I don’t have to author all the different forward and deferred fragment passes. We’re just finding a way to re-colour.

We want to also to use a pre-defined palette of book colours, not get completely random colours – this allows us to better define the colours of the objects to match a desired art style and tone.

With that in mind, we need to provide a palette of colours and offset the UVs of each book to sample a random point on the colour palette, and use that to drive the colour tint. 

So, let’s extract the colours from that bookshelf image above:

Scaled up 8*2 texture.
Lots of muted, brown tones

Now we need to flesh out our shader a bit. We’ll use the standard template that Unity gives us, then:

  • Remove the _Color property
  • Add in a new property for our colour palette texture
  • Add in a vertex program (empty for now)
Shader "LordZargon/ColorByPosition" {
	Properties {
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_ColorPalette("Colour Palette (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200

		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows vertex:vert
		#pragma target 3.0

		sampler2D _MainTex, _ColorPalette;

		struct Input {
			float2 uv_MainTex;
			float4 color : COLOR;
		};

		half _Glossiness;
		half _Metallic;

		UNITY_INSTANCING_BUFFER_START(Props)
			// put more per-instance properties here
		UNITY_INSTANCING_BUFFER_END(Props)

		void vert(inout appdata_full v) {
		}

		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
			o.Albedo =  c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Great! Now we need to decide how to get a unique value for each book that we can later pass into our psuedo-random function. 

The clue is in the first section – each book will have a unique position in the world. We can get this simply by this line (in our vertex program):

fixed3 worldPosition = mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz;

What this does is transforms the object’s local coordinates into world coordinates.

But what to do next? Well, now we need to find a random number generator that can convert 3 values into 3 random values. We can’t just use the world position co-ordinates to drive UV offset because we’ll start finding repetitions – we need randomness.

There are some varying approaches to randomness in shaders we could take.

  • Perlin noise is one, but the issue is that it isn’t random-per-pixel, it has gradations between points (by definition, it’s a gradient noise).
  • Combine transcendental numbers (such as pi) with trigonometry. This would work, but GPU accuracy on trigonometric values varies.
  • We have float3s at our disposal, so we can use dot products and frac()s to give us some variations from input values

Dave Hoskins provided a nice take on this on ShaderToy, here. And includes a function to generate random noise values.

We could use the 3-in (worldPosition.xyz) 2-out (UV offset) function, but I’ve selected the 3-in, 3-out, so you could negate the colour palette entirely and just use the random float3 we’ll generate to provide the colour tint, without needing to tweak the random function.

This is how his function looks:

///  3 out, 3 in...
vec3 hash33(vec3 p3)
{
	p3 = fract(p3 * HASHSCALE3);
 	p3 += dot(p3, p3.yxz+19.19);
	return fract((p3.xxy + p3.yxx)*p3.zyx);
}

The problem is, that’s GLSL, not CG. It’s simple enough to convert it though, as both languages support the same stuff (I’ve also changed ‘p3’ to ‘seed’ to make things more con textural).

// Random generator based on 3 values in, 3 values out by Dave Hoskins: https://www.shadertoy.com/view/4djSRW
float3 random(float3 seed)
{
	seed = frac(seed * float3(443.897, 441.423, 437.195));
	seed += dot(seed, seed.yxz + 19.19);
	return frac((seed.xxy + seed.yxx)*seed.zyx);
}

The next step is to transfer the new float3 to the surf() function so we can use it in our final colouring of our mesh.

As the colour tint is per-object, we’ll do the colour-palette sampling from the vertex program (so its per-vertex, not per-fragment). Then we’ll set that to be the vertex colour (as that’s already in our shader code and unused) to pass the tint along.

void vert(inout appdata_full v) {
	// randomizedWPos is center of object in world space, then randomized using the position as a seed
	fixed3 randomizedWPos = random(mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz);

	// randomizedWPos is then used to modify UVs against a colour palette texture, this is less efficient than just using
	// a random noise generator, but it gives us ultimate control over the output color
	fixed3 colSampled = tex2Dlod(_ColorPalette, float4(randomizedWPos.r + randomizedWPos.b, randomizedWPos.g + randomizedWPos.b , 0, 0 ) );

	// Pass through as vertex color to fragment/surface
	v.color.rgb = saturate(colSampled);
}
void surf (Input IN, inout SurfaceOutputStandard o) {
	// Albedo comes from a texture tinted by vertex color - in this case, it's randomized from a palette based on worldpos
	fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
	o.Albedo =  (c.rgb * IN.color.rgb) ;
	o.Metallic = _Metallic;
	o.Smoothness = _Glossiness;
}

Now, we get something that looks like this:

However, there’s one final problem: I’ve applied a simple texture like this:

The pages are the top half, the spines are the bottom half of the texture sheet

The pages in the books also change colour! We don’t want this, so we’ll use the _MainTex alpha channel to mask out the tint (as we’re not using alpha anyway) – this way round is an alpha value of 1 means pages, but you can reverse it as you see fit.

void surf (Input IN, inout SurfaceOutputStandard o) {
	// Albedo comes from a texture tinted by vertex color - in this case, it's randomized from a palette based on worldpos
	fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
	o.Albedo = (c.rgb * IN.color.rgb * (1 - c.a)) + (c.rgb * c.a);
	o.Metallic = _Metallic;
	o.Smoothness = _Glossiness;
}

And our final result:

We can now duplicate and batch these to our heart’s content…

Lots of books! Be sure to enable GPU instancing on your material!

And of course, we can simply change the palette to completely change the feel of the books (and scale, rotate them, to no extra cost). 

The final code:

Shader "LordZargon/ColorByPosition" {
	Properties {
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_ColorPalette("Colour Palette (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200

		CGPROGRAM
		// Physically based Standard lighting model, and enable shadows on all light types
		#pragma surface surf Standard fullforwardshadows vertex:vert

		// Use shader model 3.0 target, to get nicer looking lighting
		#pragma target 3.0

		sampler2D _MainTex, _ColorPalette;

		struct Input {
			float2 uv_MainTex;
			float4 color : COLOR;
		};

		// Random generator based on 3 values in, 3 values out by Dave Hoskins: https://www.shadertoy.com/view/4djSRW
		float3 random(float3 seed)
		{
			seed = frac(seed * float3(443.897, 441.423, 437.195));
			seed += dot(seed, seed.yxz + 19.19);
			return frac((seed.xxy + seed.yxx)*seed.zyx);
		}

		half _Glossiness;
		half _Metallic;

		// Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
		// See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
		// #pragma instancing_options assumeuniformscaling
		UNITY_INSTANCING_BUFFER_START(Props)
			// put more per-instance properties here
		UNITY_INSTANCING_BUFFER_END(Props)

		void vert(inout appdata_full v) {

			// randomizedWPos is center of object in world space, then randomized using the position as a seed
			fixed3 randomizedWPos = random(mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz);

			// randomizedWPos is then used to modify UVs against a colour palette texture, this is less efficient than just using
			// a random noise generator, but it gives us ultimate control over the output color
			fixed3 colSampled = tex2Dlod(_ColorPalette, float4(randomizedWPos.r + randomizedWPos.b, randomizedWPos.g + randomizedWPos.b , 0, 0 ) );

			// Pass through as vertex color to fragment/surface
			v.color.rgb = saturate(colSampled);
		}

		void surf (Input IN, inout SurfaceOutputStandard o) {
			// Albedo comes from a texture tinted by vertex color - in this case, it's randomized from a palette based on worldpos
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
			o.Albedo = (c.rgb * IN.color.rgb * (1 - c.a)) + (c.rgb * c.a);
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

2 thoughts on “Random Colours Shader”

  1. Hey man!
    You are a good job!
    There are two questions that need to be consulted.
    Your project and shader display seems to be wrong, not what you showed.
    The problem is that the position in the shader is an empty value of float4(0, 0, 0, 1), random(mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz);
    It is not correct for me to change to normal position. What is the problem?

    1. That line in particular is a matrix multiplication.
      The bit you refer to is:
      mul(unity_ObjectToWorld, float4(0, 0, 0, 1))
      You’re right – if I wanted to get world-space vertex coordinates, I would do this:
      mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1))
      The thing is, I don’t want vertex position, I want object position. By passing in “float4(0, 0, 0, 1)”, this gives me the origin of the object, so all the vertices in the object pass through the same set of coordinates to the random value function.

      I provided all the code I used, can you demonstrate any issues you’re having and I can take a look?

Leave a Reply

Your email address will not be published. Required fields are marked *