Liquid On Screen Effect

I really wasn’t sure what to call this Unity3D post processing effect, but it’s a pretty nice one that creates a splash of liquid across the screen. It could be used in racing game when driving through a puddle, a boating game at any time, change the color to red and you’ve got a nice blood or lava splash, or easily modify it to have liquid dripping down the screen for rainy scenes, etc. etc. Just to have an idea of what I’m talking about, you can check out a WebGL example here – just click on the Unity scene to trigger the effect. If it seems useful, I’ll try to describe it below.

The effect starts with a particle system, so we’ll create that first. Start with a circular texture (say, 128×128) with a red/green normal map. Feel free to use the one here for simplicity’s sake. I know it looks pretty weird, but we’ll use those colors later to create a faky refraction effect you typically find in water drops.

Create a new material using an alpha blended particle shader and set that texture as the material’s particle texture. In your Unity scene create a particle system using that new material. I set mine to a max of 100 particles and a burst of 100 particles in Emission. Turn off looping and play on awake, set an initial start speed to pretty high (10 to 30 or so), set the particle life to a range of 2 to 5, a start size range of 1 to 3, and a small amount of gravity (say a range of .05 to .2). Turn on the Limit Velocity over Lifetime module and dampen the speed to a .1. Use a circular shape emitter with a radius of 2 and, in the renderer, set the mode to vertical billboard. For Color over Lifetime, I alpha’d the particle out towards the end, and in Size over Lifetime, I used a downwards circular slope starting at 1 and ending around .1 or thereabouts. When viewing the particle effect in the scene window, you should wind up with something similar to what’s below.

Next we’ll want to draw these particles into a texture in order to access them in a shader so create a 2D RenderTexture named EffectTexture. For a size, I went with 960×600, but that’s only because that’s the default size of Unity’s WebGL output. You can choose what makes sense to you. In a game supporting multiple resolutions, it may make more sense to create the texture in code at runtime, but for this example creating one in the editor is fine. For mine, I went with ARGB32 format, no depth buffer and no mipmaps.

Create a second camera in your scene, set to clear to a solid color and set the color to black. Set the projection to orthographic with a size of 10 or so. The clipping plane of this camera can be small. I set the near to 1 and far to 10. Set this camera’s target texture to the EffectTexture you just created and position the camera so that it captures the particle system as best as possible. Now, we don’t want these particles to be seen by our main camera, so create a new layer in Unity named “HiddenEffects” or whatever makes sense to you.  Set the layer of your particle system to this HiddenEffects layer. For the orthographic camera you just created, set the Culling Mask to the HiddenEffects layer as well. In your Main Camera, set the Culling Mask to be everything except the HiddenEffects layer. Just a quick recap, we now have a particle effect that is drawn into a texture by an effect camera but not drawn into the game by the main camera. Just to add a bit of quick visual interest without a lot of work, I threw in a photo of Copenhagen as a 3D sprite, but if you have a 3D scene already set up you can use that. In any case, that is the scene set up complete.

The next thing we’ll want is a shader that will do all the actual work for us. In the Unity project create a new Image Effect Shader. In the shader add an _EffectTex property to make use of our render texture. I also know the first thing we’ll want to do is blur our effect texture, so we’ll add a blur function to our shader. So we don’t have to reinvent the wheel, we’ll just borrow the blur functions from the shader I talked about back in this post. For a blur amount, I went with a hardcoded, .0035, because that looked right. Feel free to play around with that or even throw in a Range property to adjust the blur amount at runtime. Our initial shader, then, looks like this:

/**
 *	Copyright (c) 2019 Devon O. Wolfgang
 *
 *	Permission is hereby granted, free of charge, to any person obtaining a copy
 *	of this software and associated documentation files (the "Software"), to deal
 *	in the Software without restriction, including without limitation the rights
 *	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 *	copies of the Software, and to permit persons to whom the Software is
 *	furnished to do so, subject to the following conditions:
 *
 *	The above copyright notice and this permission notice shall be included in
 *	all copies or substantial portions of the Software.
 *
 *	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 *	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 *	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 *	THE SOFTWARE.
 */

Shader "OneByOne/SplashEffect"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _EffectTex ("Effect Texture", 2D) = "black" {}
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        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;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            // normpdf function gives us a Guassian distribution for each blur iteration; 
            // this is equivalent of multiplying by hard #s 0.16,0.15,0.12,0.09, etc.
            float normpdf(float x, float sigma)
            {
                return 0.39894*exp(-0.5*x*x / (sigma*sigma)) / sigma;
            }
 
            fixed4 blur(sampler2D tex, float2 uv, float blurAmount)
            {
                // get our base color...
                fixed4 col = tex2D(tex, uv);
 
                // total width/height of our blur "grid":
                const int mSize = 11;
 
                // this gives the number of times we'll iterate our blur on each side 
                // (up,down,left,right) of our uv coordinate;
                // NOTE that this needs to be a const or you'll get errors about unrolling for loops
                const int iter = (mSize - 1) / 2;
 
                //run loops to do the equivalent of what's written out line by line above
                //(number of blur iterations can be easily sized up and down this way)
                for (int i = -iter; i <= iter; ++i)
                {
                    for (int j = -iter; j <= iter; ++j)
                    {
                       col += tex2D(tex, float2(uv.x + i * blurAmount, uv.y + j * blurAmount)) * normpdf(float(i), 3);
                   }
                }
 
                //return blurred color
                return col/mSize;
            }

            sampler2D _MainTex;
            sampler2D _EffectTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 fxSample = blur(_EffectTex, i.uv, .0035);
                return fxSample;
            }
            ENDCG
        }
    }
}

 

Before we go on, let’s create a new material that uses that SplashEffect shader we just created. Also, so we can start to see things, let’s create a post processor script which will apply that shader to to the scene and also trigger our particle effect. This script will want to expose two properties to the editor: the material using our shader and the particle system. Here, then is the entire post processor script:

/**
 *	Copyright (c) 2019 Devon O. Wolfgang
 *
 *	Permission is hereby granted, free of charge, to any person obtaining a copy
 *	of this software and associated documentation files (the "Software"), to deal
 *	in the Software without restriction, including without limitation the rights
 *	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 *	copies of the Software, and to permit persons to whom the Software is
 *	furnished to do so, subject to the following conditions:
 *
 *	The above copyright notice and this permission notice shall be included in
 *	all copies or substantial portions of the Software.
 *
 *	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 *	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 *	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 *	THE SOFTWARE.
 */

using UnityEngine;

public class PostProcessor : MonoBehaviour
{
    [SerializeField]
    private Material effectMaterial;

    [SerializeField]
    private ParticleSystem particles;

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        Graphics.Blit(source, destination, effectMaterial);
    }

    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            particles.Play();
        }
    }
}

 

Attach that script to the Main Camera in the Unity scene. From the project panel, drag in the material you just created to the effect material slot, and from the scene panel, drag in the particle system we created to the particles slot. Finally, we have something we can see. If you run the game at this point and click on the scene, you’ll see something like below.

This is exactly as expected – which is to say, this is our particle system drawn into the render texture then blurred.

The next step we want to take is run a bit of threshold over that blurred image. This will have the effect of setting the alpha of the blurred image to either 1 or 0 which will have a result of ‘glooping’ it up a bit. Add a float _Threshold property to the shader which ranges from 0-1 and default it to .85 (obviously you can change that later, but that’s a decent setting). We’ll calculate the threshold by stepping between the _Threshold value and the alpha value of our blurred effect sample and multiply our effect sample by that value. So update the fragment shader to look like this:

fixed4 frag (v2f i) : SV_Target
{
    fixed4 fxSample = blur(_EffectTex, i.uv, .0035);
    float thresh = step(_Threshold, fxSample.w);
    fxSample *= thresh;
    return fxSample;
}

 

If you run the game and click now, you’ll see something like below. Already the effect is really coming together.

So, what we’ve created at this point is the normal map for our liquid effect. Remember when I said before how we’d use that weird red/green particle texture to create the liquid refraction? Well, now’s the time to do that. In a nutshell what we want to do is sample our _MainTex texture using the normal map green channel for the u position and the green channel for the v position (Remember, when blitting in a post process shader, the _MainTex is actually our scene). Sounds confusing, but really, all we’re doing updating the fragment shader to the below:

fixed4 frag (v2f i) : SV_Target
{
    fixed4 fxSample = blur(_EffectTex, i.uv, .0035);
    float thresh = step(_Threshold, fxSample.w);
    fxSample *= thresh;

    fixed4 effectColor = tex2D(_MainTex, fxSample.yx) * thresh;

    return effectColor;
}

And that will give you something that looks like this:

And that is really the whole effect! The only thing left to do is blend the effect with the main texture, add a color property to change the liquid color, and maybe add an alpha property to lower the strength of the effect. I won’t go into all of that, but the final shader I used looks like this:

/**
 *	Copyright (c) 2019 Devon O. Wolfgang
 *
 *	Permission is hereby granted, free of charge, to any person obtaining a copy
 *	of this software and associated documentation files (the "Software"), to deal
 *	in the Software without restriction, including without limitation the rights
 *	to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 *	copies of the Software, and to permit persons to whom the Software is
 *	furnished to do so, subject to the following conditions:
 *
 *	The above copyright notice and this permission notice shall be included in
 *	all copies or substantial portions of the Software.
 *
 *	THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *	IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 *	AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *	LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 *	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 *	THE SOFTWARE.
 */

Shader "OneByOne/SplashEffect"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _EffectTex ("Effect Texture", 2D) = "black" {}
        _EffectColor ("Effect Color", Color) = (1,1,1,1)
        _Threshold ("Threshold", Range(0,1)) = .85
        _EffectAlpha ("Effect Alpha", Range(0,1)) = .75
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        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;
            };

            // normpdf function gives us a Guassian distribution for each blur iteration; 
            // this is equivalent of multiplying by hard #s 0.16,0.15,0.12,0.09, etc.
            float normpdf(float x, float sigma)
            {
                return 0.39894*exp(-0.5*x*x / (sigma*sigma)) / sigma;
            }
 
            fixed4 blur(sampler2D tex, float2 uv, float blurAmount)
            {
                // get our base color...
                fixed4 col = tex2D(tex, uv);
 
                // total width/height of our blur "grid":
                const int mSize = 11;
 
                // this gives the number of times we'll iterate our blur on each side 
                // (up,down,left,right) of our uv coordinate;
                // NOTE that this needs to be a const or you'll get errors about unrolling for loops
                const int iter = (mSize - 1) / 2;
 
                //run loops to do the equivalent of what's written out line by line above
                //(number of blur iterations can be easily sized up and down this way)
                for (int i = -iter; i <= iter; ++i)
                {
                    for (int j = -iter; j <= iter; ++j)
                    {
                       col += tex2D(tex, float2(uv.x + i * blurAmount, uv.y + j * blurAmount)) * normpdf(float(i), 3);
                   }
                }
 
                //return blurred color
                return col/mSize;
            }

            sampler2D _MainTex;
            sampler2D _EffectTex;
            fixed4 _EffectColor;
            float _Threshold;
            fixed _EffectAlpha;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 fxSample = blur(_EffectTex, i.uv, .0035);
                float thresh = step(_Threshold, fxSample.w);
                fxSample *= thresh;
                
                fixed4 effectColor = tex2D(_MainTex, fxSample.yx) * thresh * _EffectColor;
                fixed4 mainColor = tex2D(_MainTex, i.uv);
                
                effectColor += mainColor * (1-thresh);
                fixed4 outputColor = lerp(mainColor, effectColor, _EffectAlpha);
                
                return outputColor;
            }
            ENDCG
        }
    }
}

For my quick demo here, I used a threshold of .85, an alpha of .75, and light blue color for tint.

Hopefully this may help out. There’s plenty of room for improvement, like adding trails or subemitters to the particle effect for trailing drops of water, but this should be a good starting point for anyone looking to add some splash to their games.

 

Liquid On Screen Effect