Crumble: Platform Game Mechanics & Shaders in Unity

Matthieu Houllier shared the technical details behind his game under development Crumble: a ball movement and soft body mechanics, grass and watercolor shaders in Unity.

Introduction

Hello, I'm Matthieu Houllier, founder of the game company BRUTE FORCE. I'm working on Crumble, a dynamic physics platformer.

I graduated from LISAA Paris, a french gamedev school, and made some cool projects including a VR game and some prototypes. I’m also a game jam enthusiast and participate in different communities a lot.

You can see all of my free games on Itch.

About Crumble

Crumble is an upcoming physics platformer with challenging and interesting mechanics. You can control a small ball and use his tongue to travel at a fast speed across beautiful sceneries and unstable platforms.

The original game is actually a Ludum Dare entry I did 1 year ago. If you’re not familiar with it, it’s a game jam community where we do small games during a weekend. Here is the entry page.

The theme was “running out of space” and the idea was the following: you start with a lot of dynamic platforms and a timer, but then a cannon fires at you from far away and you need to survive for 2 minutes as space is running out!

We got a lot of players and feedbacks, so we decided to continue this prototype by making a full game keeping the main mechanics and adding the tongue as a grappling hook.

The game had a different look and performance - to begin with, everything was rendered using vertex colors only with very simple lights.

Before and now:

1 of 2

How to Roll a Ball

Crumble is a game made with Unity, so this section will cover things specific to that engine. However, the logic applies to other engines, especially when it comes to shaders and scripts.

  • MOVEMENT

For any platformer, movement is crucial. Metrics are very important and need to be controlled via scripts to adapt to various situations.

To move the ball in the direction you want (in front of the camera) you use the rotation of your camera:

ViewpointRotation = Quaternion.AngleAxis(camera.transform.rotation.eulerAngles.y, Vector3.up);
moveDirection = ViewpointRotation * new Vector3(Mathf.Clamp(inputSystem.GetHorizontal() * 2, -1, 1), 0, Mathf.Clamp(inputSystem.GetVertical() * 2, -1, 1));

The reason why you use rotation instead of direction like in a standard rolling ball is that you don't want to go up or down. To prevent your ball from slowing down when you're looking at the ground you use the Y-axis rotation of your active camera and assign it as an angle to the inputs.

If you don't have a world up like in space, for instance, you will use the direction instead.

Something else to implement is a script to lerp the player's rigidbody drag to its velocity. So when you go faster you get less resistance to the air and you keep going faster. It makes the player feel the acceleration and doesn't feel like a rigid max speed value.

float projectedMagnitude = Vector3.ProjectOnPlane(rigidbody.velocity, Vector3.up).magnitude;
rigidbody.drag = dragBaseValue * dragCurveMultiplier.Evaluate(projectedMagnitude);

You can use a lerp too instead of a curve editor.

Here's the difference without and with:

Crumble’s movement is very slimy on purpose to add an extra challenge to the platforming sections but you may not want that and will need to add more drag to the player’s rigidbody.

Another small thing that I thought about in Crumble is a script to slow down the player speed if there are no inputs registered. So the player won't slide off the platform or won't overshoot a distance because he was too slow to give the right inputs. A simple condition with input == 0 and a velocity lerp should do the trick.

  • JUMPING

Jumping should be fun and consistent.

If you use gravity, the first thing you need to do is increase the value by times 2 to 10 because you don't want the player to feel like it has no weight.

The perfect jump for my game has a very quick boost upward and a small window of low gravity at the end so that you can decide where to go at that moment.

To help with that you can lerp the jump force with time and a curve:

public AnimationCurve accelerationMultiplierOverTime = new AnimationCurve(new Keyframe[] { new Keyframe(0.0f, 1.0f), new Keyframe(0.25f, 0.2f) });




rigidbody.AddForce(Vector3.up * jumpForce * jumpAccelerationMultiplier * accelerationMultiplierOverTime.Evaluate(jumpTimer), ForceMode.Acceleration);

This will change the way the jump behaves based on the shape of the curve.

The vector up is there because I wanted to have a use for wall jump in the game even if it's not the main feature because of the chaotic nature of the physics.

Here is the code for it:

if (Vector3.Angle(averageHitNormal, Vector3.up)>= wallAngle
{
rigidbody.AddForce(Vector3.up * jumpForce * jumpAccelerationMultiplier * accelerationMultiplierOverTime.Evaluate(jumpTimer), ForceMode.Acceleration);
jumpTimer += Time.fixedDeltaTime;
}
else
{
jumpTimer = 0.0f;
}

It uses a wallAngle float to check if the normal hit raycast is above, so you don't wall jump on a too steep wall.

  • FEEDBACKS

 

This step seems superficial, but it is very important to give good feedbacks to the player.

You can add simple things like vibration if your game supports gamepad (it should)!

float impulseMagnitude = Mathf.Abs(collision.impactForceSum.magnitude);
playerInput.VibrateController(Mathf.Lerp(minVibrationForce, 1.0f, (impulseMagnitude - minCollisionForce) / maxCollisionForce), vibrationDuration);

And again, Lerp saves the day. impulseMagnitude is the collision force of your character.

You can add visual feedbacks such as trails and jump Fx like the one I have in the upper gif.

Destroy(Instantiate(jumpFx, transform.position, Quaternion.LookRotation(-args.JumpNormal)), lifetime);

And don't forget about a nice sound effect.

The Player Ball Shader

The main part of what makes it look good is the deformation that follows the jump and collision.

He’s stretchable and squishable, just like a soft body would be.

If you’re not familiar with what a soft body is, here’s an example:

This illustrates exactly what a default soft body is in physics. It’s largely used for cloth simulation and other “soft” material. But we’re gonna do it in a shader.

  • THE SMEAR EFFECT

 

The first thing we need to do is add a smear effect to deform the mesh based on its velocity.

The shader:

float hash(float n)  
{
return frac(sin(n)*43758.5453);  
}    
float noise(float3 x)  
{  
// The noise function returns a value in the range   -1.0f -> 1.0f    
float3 p = floor(x);  
float3 f = frac(x);  
f = f*f*(3.0 - 2.0*f);  
float n   = p.x + p.y*57.0 + 113.0*p.z;  
return lerp(lerp(lerp(hash(n + 0.0), hash(n   + 1.0), f.x),  
lerp(hash(n + 57.0), hash(n + 58.0), f.x), f.y),  
lerp(lerp(hash(n   + 113.0), hash(n   + 114.0), f.x),  
lerp(hash(n + 170.0), hash(n + 171.0), f.x), f.y), f.z);  
}    
void vert(inout appdata_full v, out Input o)
{  
UNITY_INITIALIZE_OUTPUT(Input, o);  
fixed4   worldPos = mul(_Object2World, v.vertex);  
fixed3 worldOffset   = _Position.xyz - _PrevPosition.xyz; // -5  
fixed3   localOffset = worldPos.xyz - _Position.xyz; // -5    
// World offset should only be behind swing  
float   dirDot = dot(normalize(worldOffset), normalize(localOffset));
fixed3   unitVec = fixed3(1, 1, 1) *   _NoiseHeight;
worldOffset = clamp(worldOffset, unitVec * -1,   unitVec);  
worldOffset *= -clamp(dirDot, -1, 0) * lerp(1, 0, step(length(worldOffset),   0));    
fixed3   smearOffset = -worldOffset.xyz * lerp(1, noise(worldPos * _NoiseScale), step(0, _NoiseScale));  
worldPos.xyz   += smearOffset;  
v.vertex = mul(_World2Object, worldPos);  
}

It’s a piece of code written by cjacobwade on github (check it out here).

You feed a noise value to the vertex shader and you apply an offset between the current position of the mesh and its position a couple of frames before. That will offset the vertex of the mesh to simulate a smear, which is great for our purpose.

The C# script to add for mesh position:

renderer.materials[materialIndex].SetVector("_Position", transform.position);
renderer.materials[materialIndex].SetVector("_PrevPosition", registeredPositions.Dequeue());

You can get the full code in the same repository.

  • WORLD SCALE

 

We have our stretch effect, now we need our squish effect. This is a bit complicated because our ball is rolling, so if we scale it in our shader, we would scale it locally and have a very weird deformation. So, we need to scale it in world space.

float4x4 worldScaleMatrix = float4x4(
_WorldScale.x, 0.0, 0.0, 0.0,
0.0, _WorldScale.y, 0.0, 0.0,
0.0, 0.0, _WorldScale.z, 0.0,
0.0, 0.0, 0.0, 1.0
);

We convert the scale from local space to world space with this world scale matrix in the vertex shader before returning the vertex position.

When the player hits something (the ground or any collider), you can get the collision point and feed it to the shader, then lerp the value for a nice animation.

For our player’s model, we had to offset the mesh by its radius and lerp it.

Here is a video showing the shader off/on:

  • EXTRA THINGS

 

I've added an effect of speed when you reach a certain speed. The effect also changes the camera fov to apply a dolly zoom.

The Grass Shader

One of the unique aspects of Crumble is how the grass feels in the game. You can roll through it and the grass will bend giving an illusion of dynamism. This adds a lot to the feel of the gameplay.

The idea behind the grass shader was being able to have an entire platform covered in grass that would react to the player's movement and jumps. So instead of doing a traditional billboard rendered grass, I made a grass shader based on the logic of Shell texturing.

  • SHELL TEXTURING

 

We know what we want for our grass, but how do we do it with a shader? The first thing that came to my mind was to use volume rendering aka shell texturing. If you’re not familiar with it, here’s an explanation:

This very informative image that explains the method in more detail is taken from here.

This is what we want to emulate, volume for our grass without thousands of vertex for each grass.

I use Blender for modeling and texturing.

This looks pretty simple to do: you need to make one layer and duplicate it (16 times in my case). The more layers you have, the more detailed it will be. I think for grass in a third person camera game, between 8 and 16 is ok. Try to keep the same distance between each plane to keep it consistent.

If you know your way around procedural and geometry shaders, you can add a variable in your shader to instantiate a number of layers instead of modeling it in a 3D software. But since mine is a surface shader and I’m still exploring shaders I edit the layers by hand.

Now, why do I have a black and white gradient on the right image?

I modified the vertex color of every plane, from black to white. Black being the base and white being the top so that in my shader, I can later use that data information to lerp the alpha of a single texture. This means that the bottom will be opaque and the top will be mostly transparent.

Now onto Unity shader:

I use Blender for modeling and texturing.

This looks pretty simple to do: you need to make one layer and duplicate it (16 times in my case). The more layers you have, the more detailed it will be. I think for grass in a third person camera game, between 8 and 16 is ok. Try to keep the same distance between each plane to keep it consistent.

If you know your way around procedural and geometry shaders, you can add a variable in your shader to instantiate a number of layers instead of modeling it in a 3D software. But since mine is a surface shader and I’m still exploring shaders I edit the layers by hand.

Now, why do I have a black and white gradient on the right image?

I modified the vertex color of every plane, from black to white. Black being the base and white being the top so that in my shader, I can later use that data information to lerp the alpha of a single texture. This means that the bottom will be opaque and the top will be mostly transparent.

Now onto Unity shader:

fixed3 noise = tex2D(_GrassTex, IN.uv_MainTex * _GrassThinness).rgb *tex2D(_NoGrassTex, IN.uv_MainTex);
fixed alpha = (clamp(noise - (IN.vertColor.r) * _GrassDensity,0, 1));
clip(alpha - IN.vertColor.r);

The noise is a combination of a grass texture and a noise texture that helps to create a little variation. And we’re clipping instead of using pure alpha transparency to avoid render queue conflict and headaches.

This is what we get:

  • MOVING GRASS

 

We have grass, now we need to make it move.

We simply use UV scrolling. Since we used textures to simulate volume we can just scroll said textures on the contrary to vertex deformation for geometric grass.

fixed2 scrollUV = IN.uv_MainTex;
fixed xScrollValue = _XScrollSpeed * _Time.x;
fixed yScrollValue = _YScrollSpeed * _Time.x;
scrollUV += fixed2(xScrollValue, yScrollValue);
half4 e = tex2D(_GrassTex, scrollUV);

I only scroll the grass pattern of the shader because I want to keep the texture that makes the hole in the grass static.

You can give it a fancy noise and pattern to make it look more alive:

  • TRAIL

 

The most fascinating part of the shader is the trail that's left behind the player when he rolls.

I use a technique of render texture to accomplish this effect, an orthographic camera points downward following the player with a height offset. Then we have a particle effect emitted by the player:

These blue particles will be rendered by the orthographic camera. Then, it will be applied onto the grass shader using global texture parameter:

float ripples =(0 - tex2D(_GlobalEffectRT, uv).b);
clip(alpha*(ripples + 1) - (IN.vertColor.r));

And the result:

  • TAKING THE TOPOLOGY INTO ACCOUNT 

 

Now things get serious because we are projecting a 2D texture onto a 3D mesh texture but the shader ignores the difference in height:

It’s not a big deal but it shows the limitation of the technique. Stubborn as I am, I’ve solved this issue. I have to give you a warning though, it is not the best way to solve it, so if you want to have the same effect in your game, I suggest you stop at this step.

I created another orthographic camera on top of the player, the same as the first one. But instead of rendering the blue particles, I’m rendering the depth pass of the image and applying it to the grass shader:

The result after Depth pass mask:

The Tongue Mechanic

The last aspect of Crumble that I wanted to share is the Tongue mechanic. To move at a fast speed or cross long gaps, our little blue ball uses his tongue as a grappling hook.

  • TETHERING

 

The first thing we need to evaluate is where the player can use his tongue. Some games choose to have fixed points the player can hook to and some others like Spiderman 2 enable you to swing from every tall building in the world, for example.

I chose to let it tether to anything in the world except some high walls to prevent the player from exploiting the mechanic.

I tried to make the tongue swings feel natural and minimized the use of visual feedback for it. It’s not yet perfect as some demo testers don’t know that they can tether without any feedbacks indicating that. But I think if you give enough time to the player to learn the mechanics they can adapt easily.

To anticipate where the player can use his tongue I shoot sphere casts from the player's position.

And as a side note, here is the direction of the sphere casts for Crumble 2D sections: 

Quaternion povQuat = inputSystem != null ? inputSystem.GetViewpointRotation(true) : Quaternion.identity;
Ray ray = new Ray(transform.position- (povQuat * Quaternion.Euler(rotation) * Vector3.forward)*1f, povQuat * Quaternion.Euler(rotation) * Vector3.forward);

if (Physics.SphereCast(ray, castRadius, out hitOut, raycastDistance, layerMask, QueryTriggerInteraction.Ignore))
{
   return true;
}

A bit of explanation on what’s going on here:

To create an ideal raycast for our tether, we need to preset directions for it. I created a tool in Unity to help me configure all the different directions and put weight on it. In my case, I want the player to tether first in front of him, then up, then on the sides. The first raycast to touch any authorized physics will return true and will be the one used to create the tether point.

In the code, we need to know the rotation state of the camera to rotate the whole system. The quaternion povQuat gets the rotation of the camera.

The ray is the direction “rotation” at the player's position multiplied by the camera’s rotation.

With all that information you can now cast a sphere (with the direction of the raycast, the radius of the sphere, the output of the sphere cast, the distance of the sphere cast, the layers to interact with, and whether or not to ignore triggers).

The sprint joint is instantiated the moment the sphere cast hits.

The sprint joint is instantiated the moment the sphere cast hits.

tetherSpringJoint = gameObject.AddComponent<SpringJoint>();

Then, you can configure it the way you want.

Take the attached rigidbody of the physics you’ve hit and put it in the connected body inside the spring joint.

  • PLAYER SWING MOVEMENT

 

This is the hardest part of the process. Now that you have a point your player can hang to, you need to be able to swing to another point like Spiderman.

Vector3 moveDirection = new Vector3(inputSystem.GetHorizontal(), 0.0f, inputSystem.GetVertical());

moveDirection *= povQuat;

Vector3 projectedVelocity = Vector3.ProjectOnPlane(rigidbody.velocity, tangent);
transform.rotation = Quaternion.LookRotation(projectedVelocity.normalized, upVector);
Vector3 force = Mathf.Sign(direction) * moveDirection;
rigidbody.AddForce(force);

Of course, there is more than that to it, but it’s the main logic behind it.

You get your analog input value with moveDirection, then calculate the projectedVelocity of the player based on his velocity and the view tangent. You rotate the player towards this projection and then apply a force to the rigidbody along the moveDirection rotated by the camera’s rotation.

You can go a different way from there: add a velocity bump when the player releases the button, wrap the tether around objects by raycasting alongside it, make the player jump before he can swing, etc.

  • INSPIRATION

 

Let's talk about what inspired me to make this watercolor shader in the first place.

As I said in my introduction, I sometimes take breaks from Crumble and participate in game jams. The last one was the 45th Ludum Dare, here is my entry.

The theme was “Start with nothing” which motivated me to make this cool base shader for the game.

  • INSPIRATION

 

Let's talk about what inspired me to make this watercolor shader in the first place.

1 of 2

I'm a big fan of Moebius and when I saw the tweet from Harry I knew I had to try and emulate that sweet style.

Quick gif of the shader in action:

  • WHAT WE WANT TO HAVE

 

For the shader, I wanted to have:

  1. Cutout texture
  2. Outline
  3. Unlit with colored shadow
  4. Moving shadow
  5. Moving textures
  6. Ink Fx Mask

 

  • THE BASE

 

We start with a base shader in Unity 3D with light and texture.

Shader "WaterColor/Base"
{
   Properties
   {
       _MainTex ("Texture", 2D) = "white" {}
                _DiffuseColor("Diffuse Color", Color) = (1, 1, 1, 1)
   }
   SubShader
   {
       Tags { "RenderType"="Opaque" }
       LOD 100

       Pass
       {
           CGPROGRAM
           #pragma vertex vert
           #pragma fragment frag
           #pragma multi_compile_fog
           #include "UnityCG.cginc"

                   fixed4 _DiffuseColor;

                   struct a2v {
                                float4 vertex : POSITION;
                                float3 normal : NORMAL;
                                float4 texcoord : TEXCOORD0;
                        };

           struct v2f
           {
                                float4 pos : SV_POSITION;
                                float2 uv : TEXCOORD0;
                                UNITY_FOG_COORDS(5)
                        };

           sampler2D _MainTex;
           float4 _MainTex_ST;

           v2f vert (a2v v)
           {
               v2f o;
                                o.pos = UnityObjectToClipPos(v.vertex);
                                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

                                UNITY_TRANSFER_FOG(o, o.pos);
                                return o;
           }

           fixed4 frag (v2f i) : SV_Target
           {
                                float2 uv = i.uv;
                                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT;
                                fixed3 texColor = lerp(tex2D(_MainTex, uv).rgb,ambient, 0);
                                texColor = lerp(texColor, 1,1 - tex2D(_MainTex, uv).a);
                            fixed3 diffuse = _DiffuseColor.rgb * smoothstep(0.35, 0.4, texColor)*texColor;
               UNITY_APPLY_FOG(i.fogCoord, diffuse);
               return fixed4(diffuse,1);
           }
           ENDCG
       }
   }
}

This is the base shader for the watercolor effect. This includes color, texture, and fog.

I simply made patches of color and used the Fx tool in Photoshop to get a clear black outline. The background is transparent because I use the alpha channel to mask the base color of the asset.

  • OUTLINE

 

The outline is pretty basic and doesn't need a lot of explanation, only later because of the ink mask I've got for the "fade" effect.

                        Pass {
                        NAME "OUTLINE"

                        Cull Front

                        CGPROGRAM

                        #pragma vertex vert
                        #pragma fragment frag
                                                #pragma multi_compile_fog

                        #include "UnityCG.cginc"

                        float _Outline,_DeckMultiplier;
                        fixed4 _OutlineColor;

                        struct a2v {
                                float4 vertex : POSITION;
                                float3 normal : NORMAL;
                        };

                        struct v2f {
                                float4 pos : SV_POSITION;
                                float3 worldPos : TEXCOORD2;
                                UNITY_FOG_COORDS(3)
                        };

                        v2f vert(a2v v) {
                                v2f o;
                                float4 pos = mul(UNITY_MATRIX_MV, v.vertex);

                                float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                                pos = pos + float4(normalize(normal), 0) * _Outline;
                                o.pos = mul(UNITY_MATRIX_P, pos);
                                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

                                UNITY_TRANSFER_FOG(o, o.pos);
                                return o;
                        }

                        float4 frag(v2f i) : SV_Target {
                                float3 ColorTemp = _OutlineColor.rgb;
                                UNITY_APPLY_FOG(i.fogCoord, ColorTemp);
                                return float4(ColorTemp, 1);
                                }

                                ENDCG
                        }

There is another solution for rendering a black outline without the use of the second pass. It’s not as precise but it is more performant. You can use the same logic as a rim light with fresnel and paint it black. Here is an example of a shader I've worked on that uses this technique to make a fake outline:

It’s a bit more stylized but does the trick.

  • ADDING THE SHADOW

 

Now we're going to have fun with shadows. It's really easy to do it in Fragment Shader, plus we can color the shadow during this process.

Shader "WaterColor/Base" {
        Properties{
                _MainTex("Main Tex", 2D) = "white" {}
                _Outline("Outline", Range(0,1)) = 0.1
                _OutlineColor("Outline Color", Color) = (0, 0, 0, 1)
                _DiffuseColor("Diffuse Color", Color) = (1, 1, 1, 1)
                _EdgeColor("EdgeColor", Color) = (1, 1, 1, 1)
        }
                SubShader{
                        Tags { "RenderType" = "Opaque" }
                        LOD 200

                        Pass {
                                NAME "OUTLINE"

                                Cull Front

                                CGPROGRAM

                                #pragma vertex vert
                                #pragma fragment frag
                                #pragma multi_compile_fog
                                #include "UnityCG.cginc"

                                float _Outline;
                                fixed4 _OutlineColor;


                                struct a2v {
                                        float4 vertex : POSITION;
                                        float3 normal : NORMAL;
                                };

                                struct v2f {
                                        float4 pos : SV_POSITION;
                                        float3 worldPos : TEXCOORD2;
                                        UNITY_FOG_COORDS(3)
                                };

                                v2f vert(a2v v) {
                                        v2f o;
                                        float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
                                        float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                                        pos = pos + float4(normalize(normal), 0) * _Outline;
                                        o.pos = mul(UNITY_MATRIX_P, pos);
                                        o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                                        UNITY_TRANSFER_FOG(o, o.pos);
                                        return o;
                                }
                                        float4 frag(v2f i) : SV_Target {
                                        float3 ColorTemp = _OutlineColor.rgb;
                                        UNITY_APPLY_FOG(i.fogCoord, ColorTemp);
                                        return float4(ColorTemp, 1);
                                        }
                                        ENDCG
                                }

                                Pass {
                                        Tags { "LightMode" = "ForwardBase" }

                                        CGPROGRAM

                                        #pragma vertex vert
                                        #pragma fragment frag

                                        #pragma multi_compile_fwdbase
                                        #pragma multi_compile_fog


                                        #include "UnityCG.cginc"
                                        #include "Lighting.cginc"
                                        #include "AutoLight.cginc"
                                        #include "UnityShaderVariables.cginc"

                                        fixed4 _DiffuseColor;
                                        sampler2D _MainTex;
                                        float4 _MainTex_ST, _EdgeColor, _ColorTop, _ColorBot;
                                        uniform float4 _ShadowColor;

                                        struct a2v {
                                                float4 vertex : POSITION;
                                                float3 normal : NORMAL;
                                                float4 texcoord : TEXCOORD0;
                                        };

                                        struct v2f {
                                                float4 pos : SV_POSITION;
                                                float2 uv : TEXCOORD0;
                                                fixed3 worldNormal : TEXCOORD1;
                                                float3 worldPos : TEXCOORD2;
                                                SHADOW_COORDS(3)
                                                UNITY_FOG_COORDS(5)
                                        };

                                        v2f vert(a2v v) {
                                                v2f o;
                                                o.pos = UnityObjectToClipPos(v.vertex);
                                                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                                                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                                                TRANSFER_SHADOW(o);
                                                UNITY_TRANSFER_FOG(o, o.pos);
                                                return o;
                                        }

                                                fixed4 frag(v2f i) : SV_Target {
                                                float2 uv = i.uv;
                                                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                                                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT;
                                                atten = smoothstep(0.7, 1.5, atten * 2);
                                                atten = smoothstep(0.2, 0.8, atten);
                                                fixed3 texColor = lerp(tex2D(_MainTex, uv).rgb, _ShadowColor + ambient, 0);
                                                texColor = lerp(texColor, 1, 1 - tex2D(_MainTex, uv).a);
                                                fixed3 diffuse = _DiffuseColor.rgb * smoothstep(0.35, 0.4, texColor)*texColor;
                                                fixed3 DiffuseTemp = ambient + (diffuse)-((1 - atten)*(1 - _ShadowColor))*_ShadowColor.a;
                                                UNITY_APPLY_FOG(i.fogCoord, DiffuseTemp);
                                                return fixed4(DiffuseTemp, 1);
                                        }

                                                ENDCG
                                }
                }
                        FallBack "Diffuse"
}

The shader is getting a bit long so I retracted it. A couple of things to pay attention to: I added the line "uniform float4 _ShadowColor;" which is the color value for the shadow. It's not a property in the shader, it's a property modifiable via shader. You can also put a _ShadowColor in the property of the shader if you want to control it via material.

There is also the use of a smoothstep function to control the shape of the shadow - that will be useful for what we need to do next!

Also, in the screenshot, you can see some grains added by the post-process of the camera. Adding grain is a good way to have that watercolor paper vibe.

  • MAKING THE SHADOW MOVE

You may not want to have this in your game but I like to have some kind of life in my environment and I prefer to do it via shaders.

A moving shadow makes the asset look watery.

I've also added a color gradient for that subtle "Moebius" color easing.

Shader "WaterColor/Base" {
        Properties{
                _MainTex("Main Tex", 2D) = "white" {}
                _NoiseTex("Noise Tex", 2D) = "white" {}
                _AquarelleTex("AquarelleTex", 2D) = "white" {}
                _Outline("Outline", Range(0,1)) = 0.1
                _OutlineColor("Outline Color", Color) = (0, 0, 0, 1)
                _DiffuseColor("Diffuse Color", Color) = (1, 1, 1, 1)
                _EdgeColor("EdgeColor", Color) = (1, 1, 1, 1)
                _DiffuseSegment("Diffuse Segment", Vector) = (0.1, 0.3, 0.6, 1.0)
                _ColorTop("Top Color", Color) = (1,1,1,1)
                _ColorBot("Bot Color", Color) = (1,1,1,1)
        }
                SubShader{
                        Tags { "RenderType" = "Opaque" }
                        LOD 200

                        Pass {
                                NAME "OUTLINE"

                                Cull Front

                                CGPROGRAM

                                #pragma vertex vert
                                #pragma fragment frag
                                #pragma multi_compile_fog
                                #include "UnityCG.cginc"

                                float _Outline;
                                fixed4 _OutlineColor;

                                struct a2v {
                                        float4 vertex : POSITION;
                                        float3 normal : NORMAL;
                                };

                                struct v2f {
                                        float4 pos : SV_POSITION;
                                        float3 worldPos : TEXCOORD2;
                                        UNITY_FOG_COORDS(3)
                                };

                                v2f vert(a2v v) {
                                        v2f o;
                                        float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
                                        float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                                        pos = pos + float4(normalize(normal), 0) * _Outline;
                                        o.pos = mul(UNITY_MATRIX_P, pos);
                                        o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                                        UNITY_TRANSFER_FOG(o, o.pos);
                                        return o;
                                }

                                float4 frag(v2f i) : SV_Target {
                                        float3 ColorTemp = _OutlineColor.rgb;
                                        UNITY_APPLY_FOG(i.fogCoord, ColorTemp);
                                        return float4(ColorTemp, 1);
                                        }
                                        ENDCG
                                }

                                Pass {
                                        Tags { "LightMode" = "ForwardBase" }

                                        CGPROGRAM

                                        #pragma vertex vert
                                        #pragma fragment frag
                                        #pragma multi_compile_fwdbase
                                        #pragma multi_compile_fog
                                        #include "UnityCG.cginc"
                                        #include "Lighting.cginc"
                                        #include "AutoLight.cginc"
                                        #include "UnityShaderVariables.cginc"

                                        fixed4 _DiffuseColor;
                                        sampler2D _MainTex, _NoiseTex, _AquarelleTex;
                                        float4 _MainTex_ST, _EdgeColor, _ColorTop, _ColorBot;
                                        uniform float4 _ShadowColor;
                                        fixed4 _DiffuseSegment;

                                        struct a2v {
                                                float4 vertex : POSITION;
                                                float3 normal : NORMAL;
                                                float4 texcoord : TEXCOORD0;
                                        };

                                        struct v2f {
                                                float4 pos : SV_POSITION;
                                                float2 uv : TEXCOORD0;
                                                float2 uv2 : TEXCOORD4;
                                                fixed3 worldNormal : TEXCOORD1;
                                                float3 worldPos : TEXCOORD2;
                                                SHADOW_COORDS(3)
                                                UNITY_FOG_COORDS(5)
                                        };

                                        v2f vert(a2v v) {
                                                v2f o;
                                                o.pos = UnityObjectToClipPos(v.vertex);
                                                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                                                o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
                                                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                                                o.uv2 = mul(unity_ObjectToWorld, v.vertex).xyz;
                                                TRANSFER_SHADOW(o);
                                                UNITY_TRANSFER_FOG(o, o.pos);
                                                return o;
                                        }

                                        fixed4 frag(v2f i) : SV_Target {

                                                float2 uv = i.uv;
                                                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
                                                fixed w = 0;
                                                fixed3 noiseColor = tex2D(_NoiseTex, (i.uv2 + (_Time.x * 3))*0.5);
                                                fixed3 aquarelleColor = tex2D(_AquarelleTex, i.uv * 1);
                                                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT;

                                                atten = smoothstep(0.7, 1.5, atten * 2 + noiseColor.r * 1);
                                                atten = smoothstep(0.2, 0.8, atten);
                                                if (atten < _DiffuseSegment.x + w) {
                                                        atten = lerp(_DiffuseSegment.x*1.0, _DiffuseSegment.y*0.0, smoothstep((_DiffuseSegment.x - w)*0.01 - 0.1, (_DiffuseSegment.x + w)*0.01 + 0.5, atten));
                                                }

                                                fixed3 texColor = lerp(tex2D(_MainTex, uv).rgb, _ShadowColor + ambient, 0);
                                                texColor = lerp(texColor, 1, 1 - tex2D(_MainTex, uv).a);
                                                fixed3 diffuse = _DiffuseColor.rgb * smoothstep(0.35, 0.4, texColor)*texColor;

                                                fixed4 c = lerp(0, _ColorTop, i.uv.y)* i.uv.y / 2;
                                                fixed4 d = lerp(0, _ColorBot, 0.5 - i.uv.y);

                                                diffuse = lerp(_ShadowColor, diffuse*saturate(aquarelleColor*0.45 + 0.65 + noiseColor * 0.15), 1);
                                                fixed3 DiffuseTemp = ambient + diffuse - ((1 - atten)*(1 - _ShadowColor))*_ShadowColor.a;

                                                UNITY_APPLY_FOG(i.fogCoord, DiffuseTemp);
                                                return fixed4(DiffuseTemp, 1);
                                        }

                                                ENDCG
                                        }


                }

                FallBack "Diffuse"
}

Don't mind the magic flying values, I'm a math wizard.

The actual import part of the shadow control is here:

atten = smoothstep(0.7, 1.5, atten * 2 + noiseColor.r * 1);
atten = smoothstep(0.2, 0.8, atten);
if (atten < _DiffuseSegment.x + w) {
atten = lerp(_DiffuseSegment.x*1.0, _DiffuseSegment.y*0.0, smoothstep((_DiffuseSegment.x - w)*0.01 - 0.1, (_DiffuseSegment.x + w)*0.01 + 0.5, atten));
}

I use a lot of smoothstep to control how the shadow line should behave, then I lerp it to the second inverted shadow line to get that saturated dark watery edge you get when you use watercolor with too much water.

There are many more things to the shader like a moving background texture declared here:

fixed3 noiseColor = tex2D(_NoiseTex, (i.uv2 + (_Time.x * 3))*0.5);

And the color gradient in these lines:

fixed4 c = lerp(0, _ColorTop, i.uv.y)* i.uv.y / 2;
fixed4 d = lerp(0, _ColorBot, 0.5 - i.uv.y);

If you want to stop there, you should. The next step is a bit more advanced and if you don't know what you're doing it will not work for you. The shader described is good for a watercolor effect without any masks or additional effects. The only thing you can change to make it better is to draw your own textures.

  • INK MASK

! If you don't want the mask use the shader code with moving shadows !

This is an example of the use of the Ink mask in the project. It's a great way to instantiate an object with Fx. And it keeps the visuals interesting and alive.

For that, you'll need a RenderTexture and a Camera to render it.

Shader "WaterColor/Base" {
        Properties{
                _MainTex("Main Tex", 2D) = "white" {}
                _NoiseTex("Noise Tex", 2D) = "white" {}
                _AquarelleTex("AquarelleTex", 2D) = "white" {}
                _Outline("Outline", Range(0,1)) = 0.1
                _OutlineColor("Outline Color", Color) = (0, 0, 0, 1)
                _DiffuseColor("Diffuse Color", Color) = (1, 1, 1, 1)
                _EdgeColor("EdgeColor", Color) = (1, 1, 1, 1)
                _DiffuseSegment("Diffuse Segment", Vector) = (0.1, 0.3, 0.6, 1.0)
                _ColorTop("Top Color", Color) = (1,1,1,1)
                _ColorBot("Bot Color", Color) = (1,1,1,1)
        }
                SubShader{
                        Tags { "RenderType" = "Opaque" }
                        LOD 200

                        Pass {
                                NAME "OUTLINE"

                                Cull Front

                                CGPROGRAM

                                #pragma vertex vert
                                #pragma fragment frag
                                #pragma multi_compile_fog
                                #include "UnityCG.cginc"

                                float _Outline;
                                fixed4 _OutlineColor;
                                uniform sampler2D _GlobalEffectRT;
                                uniform float _OrthographicCamSize;
                                uniform float3 _Position;

                                struct a2v {
                                        float4 vertex : POSITION;
                                        float3 normal : NORMAL;
                                };

                                struct v2f {
                                        float4 pos : SV_POSITION;
                                        float3 worldPos : TEXCOORD2;
                                        UNITY_FOG_COORDS(3)
                                };

                                v2f vert(a2v v) {
                                        v2f o;
                                        float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
                                        float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                                        pos = pos + float4(normalize(normal), 0) * _Outline;
                                        o.pos = mul(UNITY_MATRIX_P, pos);
                                        o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                                        UNITY_TRANSFER_FOG(o, o.pos);
                                        return o;
                                }

                                float4 frag(v2f i) : SV_Target {
                                        float2 uv2 = i.worldPos.xz - _Position.xz;
                                        uv2 = uv2 / (_OrthographicCamSize * 2);
                                        uv2 += 0.5;
                                        float fade = tex2D(_GlobalEffectRT, uv2).r;
                                        float3 ColorTemp = lerp(_OutlineColor.rgb,1, (1 - fade));
                                        UNITY_APPLY_FOG(i.fogCoord, ColorTemp);
                                        return float4(ColorTemp, 1);
                                        }

                                        ENDCG
                                }

                                Pass {
                                        Tags { "LightMode" = "ForwardBase" }

                                        CGPROGRAM

                                        #pragma vertex vert
                                        #pragma fragment frag
                                        #pragma multi_compile_fwdbase
                                        #pragma multi_compile_fog
                                        #include "UnityCG.cginc"
                                        #include "Lighting.cginc"
                                        #include "AutoLight.cginc"
                                        #include "UnityShaderVariables.cginc"

                                        fixed4 _DiffuseColor;
                                        sampler2D _MainTex, _NoiseTex, _AquarelleTex;
                                        float4 _MainTex_ST, _EdgeColor, _ColorTop, _ColorBot;
                                        uniform float4 _ShadowColor;
                                        fixed4 _DiffuseSegment;

                                        uniform sampler2D _GlobalEffectRT;
                                        uniform float _OrthographicCamSize;
                                        uniform float3 _Position;

                                        struct a2v {
                                                float4 vertex : POSITION;
                                                float3 normal : NORMAL;
                                                float4 texcoord : TEXCOORD0;
                                        };

                                        struct v2f {
                                                float4 pos : SV_POSITION;
                                                float2 uv : TEXCOORD0;
                                                float2 uv2 : TEXCOORD4;
                                                fixed3 worldNormal : TEXCOORD1;
                                                float3 worldPos : TEXCOORD2;
                                                SHADOW_COORDS(3)
                                                UNITY_FOG_COORDS(5)
                                        };

                                        v2f vert(a2v v) {
                                                v2f o;
                                                o.pos = UnityObjectToClipPos(v.vertex);
                                                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                                                o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
                                                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                                                o.uv2 = mul(unity_ObjectToWorld, v.vertex).xyz;
                                                TRANSFER_SHADOW(o);
                                                UNITY_TRANSFER_FOG(o, o.pos);
                                                return o;
                                        }

                                        fixed4 frag(v2f i) : SV_Target {
                                                float2 uv = i.uv;
                                                float2 uv2 = i.worldPos.xz - _Position.xz;
                                                uv2 = uv2 / (_OrthographicCamSize * 2);
                                                uv2 += 0.5;
                                                float fade = (tex2D(_GlobalEffectRT, uv2).r);

                                                UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

                                                fixed w = 0;
                                                fixed3 noiseColor = tex2D(_NoiseTex, (i.uv2 + (_Time.x * 3))*0.5 );
                                                fixed3 aquarelleColor = tex2D(_AquarelleTex, i.uv * 1);
                                                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT;

                                                atten = smoothstep(0.7, 1.5, atten * 2 + noiseColor.r * 1);
                                                atten = smoothstep(0.2, 0.8, atten);
                                                if (atten < _DiffuseSegment.x + w) {
                                                        atten = lerp(_DiffuseSegment.x*1.0, _DiffuseSegment.y*0.0, smoothstep((_DiffuseSegment.x - w)*0.01 - 0.1, (_DiffuseSegment.x + w)*0.01 + 0.5, atten));
                                                }

                                                atten = lerp(1, atten, fade);
                                                fixed3 texColor = lerp(tex2D(_MainTex, uv).rgb, _ShadowColor + ambient, 0);
                                                texColor = lerp(texColor, 1, 1 - tex2D(_MainTex, uv).a);
                                                fixed3 diffuse = _DiffuseColor.rgb * smoothstep(0.35, 0.4, texColor)*texColor;
                                                fixed4 c = lerp(0, _ColorTop, i.uv.y)* i.uv.y / 2;
                                                fixed4 d = lerp(0, _ColorBot, 0.5 - i.uv.y);

                                                diffuse = lerp(diffuse, 1, (1 - fade));
                                                diffuse = lerp(_ShadowColor, diffuse*saturate(aquarelleColor*0.45 + 0.65 + noiseColor * 0.15), 1);
                                                fixed3 DiffuseTemp = ambient + (diffuse) - ((1 - atten)*(1 - _ShadowColor))*_ShadowColor.a;

                                                UNITY_APPLY_FOG(i.fogCoord, DiffuseTemp);
                                                return fixed4(DiffuseTemp, 1);
                                        }
                                                ENDCG
                                }
                }
                FallBack "Diffuse"
}

This is the full final shader of the effect (I tried to clean it up a bit).

If you're not familiar with using RenderTextures in shaders let me explain that with some lines from it.

uniform sampler2D _GlobalEffectRT;
uniform float _OrthographicCamSize;
uniform float3 _Position;

Uniform means you're getting it from a global value that you can modify via script - pretty much like how I manage the shadow color.

Here is the script for it to place on the Fx Camera:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//[ExecuteInEditMode]
public class SetInteractiveShaderEffects : MonoBehaviour
{
   public RenderTexture rt;
   public string GlobalTexName = "_GlobalEffectRT";
   public string GlobalOrthoName = "_OrthographicCamSize";
   public Transform Player;
   private bool IsPlaying = false;
   
   private void Awake()
   {
           Shader.SetGlobalTexture(GlobalTexName, rt);
           Shader.SetGlobalFloat(GlobalOrthoName, GetComponent().orthographicSize);
   
   }

   private void Update()
   {
       transform.position = new Vector3(Player.position.x, Player.position.y+20, Player.transform.position.z);
       Shader.SetGlobalVector("_Position", transform.position);
       Shader.SetGlobalFloat(GlobalOrthoName, GetComponent().orthographicSize);

      if(!Application.isPlaying && IsPlaying)
       {
           IsPlaying = false;
           this.GetComponent().backgroundColor = Color.white;
           this.GetComponent().cullingMask = 1<<31;
       }
      else if(Application.isPlaying && !IsPlaying)
       {
           IsPlaying = true;
           this.GetComponent().backgroundColor = Color.black;
           this.GetComponent().cullingMask = 1 << 8;
       }
   }
}

_GlobalEffectRT Is the image rendered by the Fx Camera

_OrthographicCamSize Gives the orthographic size of said camera

_Position Is the center position of the camera

And these lines calculate the UV world of the new rendered image depending on the ortho size and position in the world:

float2 uv2 = i.worldPos.xz - _Position.xz;
uv2 = uv2 / (_OrthographicCamSize * 2);
uv2 += 0.5;

You'll need to set up a layer for your Fx to be rendered only on the Fx camera and use the red channel only for your particle because of this line:

float fade = (tex2D(_GlobalEffectRT, uv2).r);

And that's it for the watercolor shader effect in Unity.

Conclusion

Crumble is my first full commercial game and I am still working on it day and night until I can release it. I only showed the basics of my game but I have a lot more to show and there is so much direction I could take.

Crumble explores different types of mechanics around the simple movements of a ball: 2D transition, speed sections, survival mode, boss level, intense destructions, and a big feature that I'm working on right now - a 4-players co-op.

One of the things I've learned through this journey is to always plan in advance. Keep having fun with what you do, don’t be afraid to explore new techniques and new tools. Always confront your ideas with the world and the people around you. And always strive for more!

I want to thank 80.lv for this interview and their interest in my work. I am a very active game developer so you can follow me on twitter or my dev blog. I hope you've got something useful out of this and don’t hesitate to ask any questions!

Matthieu Houllie, Game Developer

Keep reading

You may find this article interesting

Join discussion

Comments 1

  • Robert Nally

    There's a lot of good stuff in here.  Thanks!

    0

    Robert Nally

    ·4 years ago·

You might also like

We need your consent

We use cookies on this website to make your browsing experience better. By using the site you agree to our use of cookies.Learn more