Sunday 5 July 2020

Halftone Post-Processing Effect in Unity 3D



The word "Halftone" describes a reprographic technique, which simulates gradients and imagery by using dots, which either vary in size or in spacing. Thus, only two colors are used in the entire image. This technique is based on a basic optical illusion. When the dots are small, the human eye interpretes the produced patterns as if they were smooth, creating the perceived brightnesses. the halftone technique used to be popular in newspaper and comic print as well as black and white photography,  but can also be used to create interesting real-time imagery in 2d and 3d games. This tutorial aims to provide you a quick and easy way to create your own, lightweight halftone post-processing effect in Unity 3D.

Step 1: Setting up Custom Post-Processing

To get started, we create a new Image Effect Shader in our Unity Assets folder (Create>Shader>Image Effect Shader). By default, Unity hides Image Effects from the inspector. Therefore, we need to open the new shader and edit the very first line:

Shader "Hidden/Halftone"
Shader "Custom/Halftone"

Of course you can give your shader any name you want.

Now we create a new Material using this new shader.



To make our new material work as a Post-Processing effect, we need to create the following C#-script  and attach it to our Camera. This new script will take care of editing the current frame before it gets rendered. To accomplish this, we use a function called Graphics.Blit(), which needs to be called in OnRenderImage. For a more detailed explanation of this, I suggest you check out this tutorial by Alan Zucconi.

using UnityEngine;

[ExecuteAlways]
public class CustomPostProcessing : MonoBehaviour {

    public Material postProcessingMaterial;

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


Now, all we need to do is assign our new Material to our script in the inspector. Unity's default Image Effect Shader inverts the colors of our image. So if you did everything right, your Scene should look something like this:


To get rid of the inversion effect, we open our shader, find the fragment shader, i.e. the part that takes care of calculating the current pixel color, and remove this line of code:

 fixed4 frag (v2f i) : SV_Target
 {
      fixed4 col = tex2D(_MainTex, i.uv);
      // just invert the colors
      col.rgb = 1 - col.rgb;
      return col;
}


Now, everything should look as before:











Step 2: Dividing the Screen Into Cells

Now we start coding the actual effect. We start by dividing the screen into little cells, each of which will contain one dot. This is process can be compared to reducing the resolution of the picture.

Firstly, let's start by adding a new property to set up our desired cell size. We can also hide the _MainTex property, as it will not be set by the user himself, as on a normal material. The main texture of an image effect is the current frame itself, which is set automatically in our post-processing script, so there is no need to manually set it.


Properties
{
    [HideInInspector] _MainTex ("Texture", 2D) = "white" {}

    [Header(Dots)]
    _CellSize("Cell Size", float) = 6

 }

sampler2D _MainTex;
float _CellSize;

Next, we round the UV coordinates, i.e. the current pixel's relative position on the screen and multiply it by our desired cell size. To keep the cell's ratio independent from the screen resolution, we additionally need to divide the cell size by our screen's width and height, saved in a built-in variable called "_ScreenParams".

fixed4 frag (v2f i) : SV_Target
{
        //Create Cells
        float cellWidth = _CellSize / _ScreenParams.x;
        float cellHeight = _CellSize / _ScreenParams.y;
        fixed2 roundedUV;
        roundedUV.x = round(i.uv.x / cellWidth) * cellWidth;
        roundedUV.y = round(i.uv.y / cellHeight) * cellHeight;
                 
        fixed4 col = tex2D(_MainTex, i.uv);
        return col;
}

Next up, we replace the tex2D function and use the rounded uv as the source for our output color, instead of the actual given uv, in order to display each of our cells in a different color.

fixed4 frag (v2f i) : SV_Target
{
        //Create Cells     
        float cellWidth = _CellSize / _ScreenParams.x;
        float cellHeight = _CellSize / _ScreenParams.y;
        fixed2 roundedUV;
        roundedUV.x = round(i.uv.x / cellWidth) * cellWidth;
        roundedUV.y = round(i.uv.y / cellHeight) * cellHeight;
                 
        fixed4 col = tex2D(_MainTex, i.uv);
        return col;
        fixed4 roundedCol = tex2D(_MainTex, roundedUV);
        return roundedCol;
}

This divides our image into cells, each of it having a different color. This gives it a low resolution look, as if the image was downscaled.




Step 3: Drawing Dots

Let's start drawing a dot in each cell. The plan is, to detect the distance between the uv coordinate and the rounded uv coordinate, i.e. the distance to the cell center. This way we can determine, if the current pixel is on a dot, or outside of it. Using this distance, we can use the smoothstep function, to gives us a value, which we can use to correctly interpolate between our background color and our dot color. We start by calculating the distance to the cell center:

fixed4 frag (v2f i) : SV_Target
{
        //Create Cells
        float cellWidth = _CellSize / _ScreenParams.x;
        float cellHeight = _CellSize / _ScreenParams.y;
        fixed2 roundedUV;
        roundedUV.x = round(i.uv.x / cellWidth) * cellWidth;
        roundedUV.y = round(i.uv.y / cellHeight) * cellHeight;

        //Calculate Distance From Cell Center
        float2 distanceVector = i.uv - roundedUV;
        distanceVector.x = (distanceVector.x / _ScreenParams.y) * _ScreenParams.x;
        float distanceFromCenter = length(distanceVector);

        fixed4 roundedCol = tex2D(_MainTex, roundedUV);
        return roundedCol;
}

As you can see, we modify the x-value of the distance vector to make up for the screen ratio. Otherwise, our dots would scale with the screen and become ovals.

To draw the dots, we need to add two more properties: The size of the dots and the smoothness of each dot's edges. The smoothness should be set to a really small values, to keep the dots nice and crisp. However, don't set this value to zero, as our smoothstep function will not be able to output any result then.

Properties
{
        [HideInInspector] _MainTex ("Texture", 2D) = "white" {}

        [Header(Dots)]
        _CellSize("Cell Size", float) = 6
        _DotSize("Dot Size", float) = 6
        _DotSmoothness("Smoothness", Range(0,0.01)) = 0.002
}

sampler2D _MainTex;
float _CellSize;
float _DotSize;
float _DotSmoothness;

Now, we can use the smoothstep function to evaluate if we should draw the dot color or the background color, or if we should interpolate between the two colors (if the pixel is right on the dot's edge). Let's use black (float4(0,0,0,1)) and white (float4(1,1,1,1)) as placeholder colors for the dots and the background.

fixed4 frag (v2f i) : SV_Target
{
            //Create Cells
            float cellWidth = _CellSize / _ScreenParams.x;
            float cellHeight = _CellSize / _ScreenParams.y;
            fixed2 roundedUV;
            roundedUV.x = round(i.uv.x / cellWidth) * cellWidth;
            roundedUV.y = round(i.uv.y / cellHeight) * cellHeight;

             //Calculate Distance From Cell Center
            float2 distanceVector = i.uv - roundedUV;
            distanceVector.x = (distanceVector.x / _ScreenParams.y) * _ScreenParams.x;
            float distanceFromCenter = length(distanceVector);

            //Calculate Dot Size
            float dotSize = _DotSize / _ScreenParams.x;

            fixed4 roundedCol = tex2D(_MainTex, roundedUV);
            return roundedCol;

            //Calculate Displayed Color
            float lerpAmount = smoothstep(_DotSize, _DotSize + _DotSmoothness, distanceFromCenter);
            fixed4 col = lerp(float4(0, 0, 0, 1), float4(1, 1, 1, 1), lerpAmount);
            return col;
}


If we did everything right, setting the Dot Size on the material to something lower than the Cell Size, will reveal that for every cell in the picture a dot gets drawn:


Lastly, lets add two more properties for the dot color and the background color:

Properties
{
        [HideInInspector] _MainTex ("Texture", 2D) = "white" {}

        [Header(Dots)]
        _CellSize("Cell Size", float) = 6
        _DotSize("Dot Size", float) = 6
        _DotSmoothness("Smoothness", Range(0,0.01)) = 0.002

        [Header(Color)]
        _BackgroundColor("Background Color", Color) = (0.85,0.85,0.85,1)
        _DotColor("Dot Color", Color) = (0,0,0,1)
}

sampler2D _MainTex;
float _CellSize;
float _DotSize;
float _DotSmoothness;
fixed4 _BackgroundColor;
fixed4 _DotColor;

We should also replace our two placeholder values in the fragment shader with those new properties.

fixed4 frag (v2f i) : SV_Target
{
            //Create Cells
            float cellWidth = _CellSize / _ScreenParams.x;
            float cellHeight = _CellSize / _ScreenParams.y;
            fixed2 roundedUV;
            roundedUV.x = round(i.uv.x / cellWidth) * cellWidth;
            roundedUV.y = round(i.uv.y / cellHeight) * cellHeight;

             //Calculate Distance From Cell Center
            float2 distanceVector = i.uv - roundedUV;
            distanceVector.x = (distanceVector.x / _ScreenParams.y) * _ScreenParams.x;
            float distanceFromCenter = length(distanceVector);

            //Calculate Dot Size
            float dotSize = _DotSize / _ScreenParams.x;

            fixed4 roundedCol = tex2D(_MainTex, roundedUV);

            //Calculate Displayed Color
            float lerpAmount = smoothstep(_DotSize, _DotSize + _DotSmoothness, distanceFromCenter);
            fixed4 col = lerp(float4(0, 0, 0, 1), float4(1, 1, 1, 1), lerpAmount);
            fixed4 col = lerp(_DotColor, _BackgroundColor, lerpAmount);
            return col;
}

Step 4: Calculating Dots Sizes

This is actually much easier than it sounds. All we need to do, is to get the relative luminosity (i.e. the perceived brightness) of the "rounded" Color and multiply it with the dot size. For this, we use the so-called luminosity function. Multiplying the dot size with 1-luminosity will make lighter cells have smaller dots and darker cells have larger dots. You can also try using the unmodified uv to get the luminosity, giving you skewed dots, but a slightly more realistic look.

fixed4 frag (v2f i) : SV_Target
{
        //Create Cells
        float cellWidth = _CellSize / _ScreenParams.x;
        float cellHeight = _CellSize / _ScreenParams.y;
        fixed2 roundedUV;
        roundedUV.x = round(i.uv.x / cellWidth) * cellWidth;
        roundedUV.y = round(i.uv.y / cellHeight) * cellHeight;

        //Calculate Distance From Cell Center
        float2 distanceVector = i.uv - roundedUV;
        distanceVector.x = (distanceVector.x / _ScreenParams.y) * _ScreenParams.x;
        float distanceFromCenter = length(distanceVector);

        //Calculate Dot Size
        float dotSize = _DotSize / _ScreenParams.x;
        fixed4 roundedCol = tex2D(_MainTex, i.uv); //use roundedUV instead of i.uv for different effect
        float luma = dot(roundedCol.rgb, float3(0.2126, 0.7152, 0.0722));
        dotSize *= (1 - luma);

        //Calculate Displayed Color
        float lerpAmount = smoothstep(dotSize, dotSize + _DotSmoothness, distanceFromCenter);
        fixed4 col = lerp(_DotColor, _BackgroundColor, lerpAmount);
        return col;
}

And done! You can now enjoy all of your Unity scenes with your very own Halftone Post-Processing effect!