Sponsored By

Featured Blog | This community-written post highlights the best of what the game industry has to offer. Read more like it on the Game Developer Blogs.

Step by step walk through the process of converting a static/flat art style to a more "alive" by modifying only a few lines of shader code.

Radu Muresan, Blogger

August 4, 2015

9 Min Read

My current game started off with a very flat art style - everything was precise, exact, straight lines everywhere. With the change of name and theme for the game, I wanted to go for a more fluid/organic look. First thing I changed was the main characters. However, that made them look odd in the environment they were in so it resulted in having to change everything else.


Coming up with something different for the level geometry was tricky - I didn't want to redo all the art or rebuild the levels so shaders to the rescue.


I already had a modified shader to display the neon geometry, using two different textures - one for the white-neon center and another for the colored glow around it - in the interest of simplifying this, I will try to edit that piece out. The following piece assumes some beginner knowledge of shaders.

1. Beginning

My shader code was initially this :


fixed4 frag_mult(v2f_vct i) : COLOR
{ 
    fixed4 result = tex2D(_MainTex, i.texcoord) * i.color;
    return result;
}

Which looked like:

2. Vertical Offset

Let's start simple by offsetting this vertically -  changing the lookup position.

This:


float2 displacedTexCoord = i.texcoord + float2(0, .05);
fixed4 result = tex2D(_MainTex, displacedTexCoord) * i.color;
return result;

Looks like:

Not very pretty, but useful in understanding what's going on - see how it's vertically offset, enough that the tiles in the tilemap actually take their texture from a different sprite in the spritesheet.

3. Vertical Movement

Now let's add some movement to it, easiest way to achieve that is to use the _Time component that Unity supplies to shaders:


float2 displacedTexCoord = i.texcoord + float2(0, cos(_Time.x * 50))/20;

Even with just this simple example, there are two values we can tweak. The 50 affects how fast the cycle will go, while the 20 is linked to how wide the swing is.

Here's an example of slower movement that uses float2(0, cos(_Time.x * 25))/20:

And here's an example of less ample movement, using float2(0, cos(_Time.x * 50))/40. Notice how in this example, it doesn't go into neighboring sprites like before.

4. Full movement

Adding some movement on the x axis as well:


float2 displacedTexCoord = i.texcoord + 
    float2(sin(_Time.x * 50), cos(_Time.x * 50))/40;

5. Static Randomness

Now all of this is pretty boring, so let's introduce a bit of randomness. Since coming up with random numbers inside shader code is tricky, the easiest way is to use a noise texture. Perlin noise images are pretty easy to find, just search for one. We add it as a property to the shader, set it and now we can use it. If you're actually trying to do this, one problem you'll run into is these weird cutoff lines - that's being caused by using a non-seamless perlin noise texture. Search for a seamless one so everything wraps around nicely.

Starting with no movement:


float2 displacedTexCoord = i.texcoord + float2(tex2D(_NoiseTex, i.texcoord).xy)/20;

We have a few things we can tweak here as well. The 20 at the end controls how big the displacement will be. Using 40 looks like this, notice it's less jagged:

6. Animated Randomness

Once again, pretty boring, so let's bring in some trig functions.


float2 displacedTexCoord = i.texcoord + float2(
    tex2D(_NoiseTex, i.texcoord + float2(_SinTime.w, _CosTime.w)/10).xy)/25;

That's more like it:

However, if you look at it closely, you can see cyclic directionality to the movement: for vertical walls, it goes up and down, while for horizontal walls it goes left and right. We'll come back to how to solve this later. There's also another problem hidden here that you can't see because of the values being used.

If you watch this one closely you'll see how it's all repeated. Humans are really good at picking up patterns so this stands out. Also, take a close look at the edges of the tiles, you'll see sharp drops every once in a while.

7. Seamless randomness

What's happening here is that the lookup in the noise texture is made based on the position in the original texture - that means that two neighboring tiles that are identical will have identical noise, which doesn't always line up nicely. To solve this, we need to make the lookup be based on its absolute position.

That needs to be set up before hand like this: o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);. This line goes into the vertex function, not the fragment function.

I won't go too much into basic shader concepts, but here's a handy link if this doesn't make much sense.

Once that's being passed in properly, we can do this:


float2 displacedTexCoord = i.texcoord + float2(
  tex2D(_NoiseTex, i.vertex.xy/300 + float2(_SinTime.w, _CosTime.w)/10).xy)/20;

Notice the i.vertex.xy - that will ensure that neighboring pixels in the output will grab neighboring positions from the noise texture. This fixes the "tiledness" problem but makes the cyclic movement problem even more obvious.

8. Seamless directionless randomness

To fix the directionality issue, we'll move away from the trig functions and do a linear offset:


float2 displacedTexCoord = i.texcoord + 
    float2(tex2D(_NoiseTex, i.vertex.xy/300 + float2((_Time.w%50)/50, (_Time.w%50)/50)).xy)/20;

Notice how the directionality is constant right now, it always goes towards the top left corner. The trickery there with (_Time.w%50)/50 is to constrain the _Time.w (which is an ever increasing value) to the 0-1 range.

8a. Pinpointing the problem

To actually explain this solution, let's try a few more steps, first by removing the offset on the x axis:


float2 displacedTexCoord = i.texcoord + float2(
    0,
    tex2D(_NoiseTex, i.vertex.xy/300 + float2((_Time.w%50)/50, (_Time.w%50)/50)).z
)/20;

Notice how I'm offsetting by 0 on the x and by the .z component of the noise texture lookup on the y (I picked the .z so this is less confusing, really all we're doing is picking one of the color channels of that noise pixel we just looked up). It now looks like this (the vertical lines are still now):

I'm going to attempt describing the problem by using an analogy.

Imagine you have a mailbox slot that's very wide and not tall at all (say 1 foot wide and 0.1' tall). Also imagine you're looking through said slot and on the other side there's someone holding a tie-dye t-shirt (a random pattern) and moving it. Now if the t-shirt is moved left to right, you'll see the directionality of it right away as your previous frame of reference is still partially in view. If the shirt is moved up and down, everything will appear random.

Here's some images showing this in action. Full movement:

 

Same images viewed through a small viewport:

 

See how the sideways movement is immediately visible on the left but the vertical movement appears random on the right?

8b. Fixing the problem

Now back to out problem, what we need to do is move the texture the right way. This is as easy as not moving it on the x axis:


float2 displacedTexCoord = i.texcoord + float2(
   0,
   tex2D(_NoiseTex, i.vertex.xy/300 + float2(0, (_Time.w%50)/50)).z
)/20; 

To make it wavy vertically again, we apply the same principle to the x axis. Putting both of them together looks like this:


float2 displacedTexCoord = i.texcoord + float2(
    tex2D(_NoiseTex, i.vertex.xy/300 + float2((_Time.w%50)/50, 0)).z,
    tex2D(_NoiseTex, i.vertex.xy/300 + float2(0, (_Time.w%50)/50)).z
)/20;

8c. Another small fix

One more problem we have to fix is to make the offset be symmetrical. Right now, since it's picking the .z component (which as mentioned above is the blue color channel) its value will be between 0 and 1. Subtracting .5 will make it go to -0.5 to 0.5 so everything is centered nicely:


float2 displacedTexCoord = i.texcoord + float2(
    tex2D(_NoiseTex, i.vertex.xy/300 + float2((_Time.w%50)/50, 0)).z - .5,
    tex2D(_NoiseTex, i.vertex.xy/300 + float2(0, (_Time.w%50)/50)).z - .5
)/20;

9. Wrapping up

Bringing back in the neon-y goodness we took out at the beginning wraps this whole thing up and makes it look like this:

Final result

A few more tweaks of all the numbers I mentioned before (trivial to do if exposed as shader properties), add some background (which uses a modified version of the same shader) and here's the final result:

If you want to see more posts like this, follow the development of the game on its devlog, twitter or website.

Read more about:

Featured Blogs

About the Author(s)

Daily news, dev blogs, and stories from Game Developer straight to your inbox

You May Also Like