shader - watercolor effect
wanted result
A controllable watercolor stain!
the setup

For this tutorial, I'm using Unity 6. Note that everything we are doing here should also work in older versions, as it doesn't use anything specific from Unity 6. However, some things must be named differently, mostly in the RendererFeature section.

Because, yes, I'm using the URP, to have access to the RendererFeatures, which will be useful to apply a grainy paper texture over the screen. I'll show you how to create the watercolor shader in HLSL. So if you're not comfortable with this, I encourage you to pay close attention to the tutorial steps to better understand the logic behind it! I won't explain everything about the shaders' magic here, but I'll try my best to be crystal clear about what we're doing in this shader to achieve our watercolor effect.

Let's setup the scene properly:
- add a plane in your scene (GameObject > 3D Object > Plane)
- create a 'watercolor' shader (Assets > Create > Shader > Unlit shader)
- create a material (right-click on the watercolor shader > Create > Material)
- apply the material on the plane.

Let's open our shader, and replace everything with this:

  1. Shader "Watercolor"
  2. {
  3. Properties // every public shader properties shown in the Inspector
  4. {
  5. _Color("Color", Color) = (1,1,1,1)
  6. }
  7.  
  8. SubShader
  9. {
  10. Tags
  11. {
  12. "Queue" = "Transparent" "RenderType" = "Transparent"
  13. }
  14.  
  15. Pass // Define the shader pass
  16. {
  17. Blend SrcAlpha OneMinusSrcAlpha // Use common transparency
  18.  
  19. HLSLPROGRAM // the HLSL code starts here
  20. #pragma vertex vert // define vertex function (called per vertex)
  21. #pragma fragment frag // define fragment function (called per fragment/pixel)
  22.  
  23. // include Unity URP core functions
  24. #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
  25.  
  26. // data received from the GPU workflow
  27. struct appdata
  28. {
  29. float3 posOS : POSITION; // vertex position in object space
  30. UNITY_VERTEX_INPUT_INSTANCE_ID
  31. };
  32.  
  33. // data we send from the vertex shader to the fragment shader
  34. struct v2f
  35. {
  36. float4 posCS : SV_POSITION; // fragment position in clip space
  37. UNITY_VERTEX_INPUT_INSTANCE_ID
  38. UNITY_VERTEX_OUTPUT_STEREO
  39. };
  40.  
  41. CBUFFER_START(UnityPerMaterial)
  42. half4 _Color;
  43. CBUFFER_END
  44.  
  45. v2f vert(appdata _input)
  46. {
  47. v2f output;
  48.  
  49. UNITY_SETUP_INSTANCE_ID(_input);
  50. UNITY_TRANSFER_INSTANCE_ID(_input, output);
  51. UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
  52.  
  53. // use matrices to transform the vertex position from object space to clip space
  54. output.posCS = TransformObjectToHClip(_input.posOS);
  55. return output;
  56. }
  57.  
  58. half4 frag(v2f _input) : SV_Target
  59. {
  60. UNITY_SETUP_INSTANCE_ID(_input);
  61. UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(_input);
  62.  
  63. return _Color; // just returning the defined color
  64. }
  65. ENDHLSL // end of the HLSL code
  66. }
  67. }
  68. }

This shader is very basic and only allows us to set the material color. Once again, I won't explain how it works, but I put some comments to help a bit.

the shader watercolor texture

Now, we can create our shader. I took some inspiration from this awesome tutorial by Kai on youtube.
These are the steps we'll go through:
- implementing a Voronoi pattern to have color variations on our surface
- smoothing the voronoi pattern result in order to smooth the color variations, and get our watercolor effect
- using a radius to create a stain on our surface.

Let's start with the first step. For getting the voronoi function, I simply checked the unity shadergraph's voronoi node source code. If you want more information about Voronoi, don't hesitate to check the wikipedia's page.
Anyway, this is the result you can put over the frag function.

  1. inline float2 GetVoronoiRandomVector(float2 _uv, float _offset)
  2. {
  3. float2x2 m = float2x2(15.27, 47.63, 99.41, 89.98);
  4. _uv = frac(sin(mul(_uv, m)) * 46839.32);
  5. return float2(sin(_uv.y * _offset) * 0.5 + 0.5, cos(_uv.x * _offset) * 0.5 + 0.5);
  6. }
  7.  
  8. void Voronoi_float(float2 _uv, float _angleOffset, float _cellDensity, out float _noise, out float _cells)
  9. {
  10. float2 g = floor(_uv * _cellDensity);
  11. float2 f = frac(_uv * _cellDensity);
  12. float3 res = float3(8.0, 0.0, 0.0);
  13.  
  14. for (int y = -1; y <= 1; y++)
  15. {
  16. for (int x = -1; x <= 1; x++)
  17. {
  18. float2 lattice = float2(x, y);
  19. float2 offset = GetVoronoiRandomVector(lattice + g, _angleOffset);
  20. float d = distance(lattice + offset, f);
  21. if (d < res.x)
  22. {
  23. res = float3(d, offset.x, offset.y);
  24. _noise = res.x;
  25. _cells = res.y;
  26. }
  27. }
  28. }
  29. }

As you can see, there are two functions: 'GetVoronoiRandomVector' and 'Voronoi_float'. GetVoronoiRandomVector is called by Voronoi_float, and this is Voronoi_float we'll call in our frag function to apply the voronoi noise.

The Voronoi_float function has several parameters:
- a float2 '_uv', a float '_angleOffset' and a float '_cellDensity' we need to provide to the Voronoi algorithm,
- a float '_noise' and a float '_cells' values, which will contain the voronoi results.

To start, we'll provide the object UV. To do so, we need to get the object UV from the appdata struct. And send it to our fragment shader through the v2f struct.

  1. struct appdata
  2. {
  3. float3 posOS : POSITION;
  4. float2 uv : TEXCOORD0; // TEXCOORD0 contains the object UV
  5. UNITY_VERTEX_INPUT_INSTANCE_ID
  6. };

  1. struct v2f
  2. {
  3. float4 posCS : SV_POSITION;
  4. float2 uv : TEXCOORD0; // so we'll use the UV during fragment shader
  5. UNITY_VERTEX_INPUT_INSTANCE_ID
  6. UNITY_VERTEX_OUTPUT_STEREO
  7. };

In the vert function, we need to set the UV in the v2f struct instance 'output'.

  1. v2f vert(appdata _input)
  2. {
  3. v2f output;
  4.  
  5. UNITY_SETUP_INSTANCE_ID(_input);
  6. UNITY_TRANSFER_INSTANCE_ID(_input, output);
  7. UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
  8.  
  9. output.posCS = TransformObjectToHClip(_input.posOS);
  10. output.uv = _input.uv; // as simple as that!
  11. return output;
  12. }

Then, we'll provide the angle offset and the cell density through two parameters. We declare them in the Properties section to be able to define them in the Unity Inspector. And we declare them in the HLSL code so we can use them in our frag function.

  1. Properties
  2. {
  3. _Color("Color", Color) = (1,1,1,1)
  4. _AngleOffset("Angle offset", Float) = 1.0
  5. _CellDensity("Cell density", Float) = 1.0
  6. }

  1. CBUFFER_START(UnityPerMaterial)
  2. half4 _Color;
  3. half _AngleOffset;
  4. half _CellDensity;
  5. CBUFFER_END

Now, we have everything we need to give to the Voronoi function. Let's call it in the frag function!

  1. half4 frag(v2f _input) : SV_Target
  2. {
  3. UNITY_SETUP_INSTANCE_ID(_input);
  4. UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(_input);
  5.  
  6. float noise = 0.0, cells = 0.0; // we declare values to store voronoi results
  7. Voronoi_float(_input.uv, _AngleOffset, _CellDensity, noise, cells); // Voronoi's magic
  8.  
  9. float4 color = _Color;
  10. color *= noise; // we multiply the color with the voronoi noise
  11. return color;
  12. }

Back to Unity. You can select the Watercolor material, set the Cell Density to 5 for instance, and play with the Angle offset value to see the result.

At this point, we don't want any transparency. It happens because we also multiply the color alpha by the noise value. To prevent that, we can explicitly ask to multiply only the rgb values.

  1. color.rgb *= noise; // we multiply the color with the voronoi noise

Also, for our shader, we don't need the noise result, but the cells result. This cells value range is [0;1], so we can add 0.5 to multiply the color by a range of [0.5;1.5], in order to have color variations a bit darker or a bit brighter than the given color (instead of just darker).

  1. color.rgb *= cells + 0.5;

New step: we have to smooth the voronoi cells to simulation the color variations properly. In his tutorial, Kai simply activates the "smooth" option on the Blender's voronoi node, which we obviously don't have here. So I checked directly the Blender's git source code to implement it and update the Voronoi_float function accordingly.

  1. void Voronoi_float(float2 _uv, float _angleOffset, float _cellDensity, float _smoothness, out float _noise, out float _cells)
  2. {
  3. float2 g = floor(_uv * _cellDensity);
  4. float2 f = frac(_uv * _cellDensity);
  5. //float3 res = float3(8.0, 0.0, 0.0);
  6.  
  7. float smoothDistance = 0.0;
  8. _cells = 0.0;
  9. _noise = 0.0;
  10. float h = -1.0;
  11.  
  12. for (int y = -1; y <= 1; y++)
  13. {
  14. for (int x = -1; x <= 1; x++)
  15. {
  16. float2 lattice = float2(x, y);
  17. float2 offset = GetVoronoiRandomVector(lattice + g, _angleOffset);
  18. float d = distance(lattice + offset, f);
  19. // if (d < res.x)
  20. // {
  21. // res = float3(d, offset.x, offset.y);
  22. // _noise = res.x;
  23. // _cells = res.y;
  24. // }
  25.  
  26. h = h == -1.0f ? 1.0 : smoothstep(0.0, 1.0, 0.5 + 0.5 * (smoothDistance - d) / _smoothness);
  27. float correctionFactor = _smoothness * h * (1.0 - h);
  28. smoothDistance = lerp(smoothDistance, d, h) - correctionFactor;
  29. correctionFactor /= 1.0 + 3.0 * _smoothness;
  30.  
  31. float cell = offset.x;
  32. _cells = lerp(_cells, cell, h) - correctionFactor;
  33.  
  34. float pos = d;
  35. _noise = lerp(_noise, pos, h) - correctionFactor;
  36. }
  37. }
  38. }

As you can see, we now have a _smoothness parameter to fulfill! Let's add a _Smoothness parameter in our shader.

  1. Properties
  2. {
  3. _Color("Color", Color) = (1,1,1,1)
  4. _AngleOffset("Angle offset", Float) = 1.0
  5. _CellDensity("Cell density", Float) = 1.0
  6. _Smoothness("Smoothness", Range(0,1)) = 0.5
  7. }

Declare it in the HLSL code as well.

  1. CBUFFER_START(UnityPerMaterial)
  2. half4 _Color;
  3. half _AngleOffset;
  4. half _CellDensity;
  5. half _Smoothness;
  6. CBUFFER_END

And finally set it to the updated Voronoi_float, within the frag function.

  1. Voronoi_float(_input.uv, _AngleOffset, _CellDensity, _Smoothness, noise, cells); // Voronoi's magic

Our voronoi cells are now smoothed. Now, we can add a _Power variable to control the variation power. Same ritual: add a _Power to properties, declare it in HLSL code and use it in the frag function.

  1. Properties
  2. {
  3. _Color("Color", Color) = (1,1,1,1)
  4. _AngleOffset("Angle offset", Float) = 1.0
  5. _CellDensity("Cell density", Float) = 1.0
  6. _Smoothness("Smoothness", Range(0,1)) = 0.5
  7. _Power("Power", Range(0,5)) = 1.0
  8. }

  1. CBUFFER_START(UnityPerMaterial)
  2. half4 _Color;
  3. half _AngleOffset;
  4. half _CellDensity;
  5. half _Smoothness;
  6. half _Power;
  7. CBUFFER_END

  1. float4 color = _Color;
  2. cells = pow(cells + 0.5, _Power); // apply power
  3. color.rgb *= cells; // we multiply the color with the voronoi noise
  4. return color;

With this, we can play with the Watercolor material Smoothness and Power to set the color variation we are looking for (and don't forget the Cell density for changing the cells size, and the Angle offset for updating the cells shapes).

the shader stain effect

For the stain effect, we need a center pos, and a radius.

  1. Properties
  2. {
  3. _Color("Color", Color) = (1,1,1,1)
  4. _AngleOffset("Angle offset", Float) = 1.0
  5. _CellDensity("Cell density", Float) = 1.0
  6. _Smoothness("Smoothness", Range(0,1)) = 0.5
  7. _Power("Power", Range(0, 5)) = 1.0
  8. _Center("Stain center", Vector) = (0,0,0,0) // ready for
  9. _Radius("Stain radius", Float) = 1.0 // action!
  10. }

As with everything else in shaders, there are many ways to do things. The choices will depend of our needs. Here, we have a plane facing the Y-axis. So, instead of using the object UV, we can use the position's X and Z in world space.
To do so, we need to store the position in world space in the v2f structure, in order to use in the fragment shader (= in our frag function). First, we add the posWS value in the v2f struct.

  1. struct v2f
  2. {
  3. float4 posCS : SV_POSITION;
  4. float2 uv : TEXCOORD0;
  5. float3 posWS : VAR_POS;
  6. UNITY_VERTEX_INPUT_INSTANCE_ID
  7. UNITY_VERTEX_OUTPUT_STEREO
  8. };

Then, we need to fulfill this variable during the vertex shader (= our vert function).
Instead of using the TransformObjectToHClip function, which directly set our vertex position from object space to clip space, we'll use:
- the TransformObjectToWorld function to get the vertex position in world space,
- then, the TransformWorldToHClip function, to get the vertex position in clip space.

Note, that we could also simply use the TransformObjectToWorld function aside of the TransformObjectToHClip function. But TransformObjectToHClip is kind of a mix of TransformObjectToWorld + TransformWorldToHClip, it's better for performance to do this:

  1. // output.posCS = TransformObjectToHClip(_input.posOS);
  2. output.posWS = TransformObjectToWorld(_input.posOS);
  3. output.posCS = TransformWorldToHClip(output.posWS);
  4. output.uv = _input.uv;
  5. return output;

Now, we can use this posWS in the frag function. We need to check the fragment/pixel world pos, check its distance from the given _Center. If the distance is bigger than _Radius value, then it's out of the strain, and we set color alpha to 0.
First, we need to declare _Center and _Radius in the HLSL code.

  1. CBUFFER_START(UnityPerMaterial)
  2. half4 _Color;
  3. half _AngleOffset;
  4. half _CellDensity;
  5. half _Smoothness;
  6. half _Power;
  7. half2 _Center; // only half2, because we are on a plane here
  8. half _Radius;
  9. CBUFFER_END

Now, let's add stain logic in the frag function! (check the comments for explanations)

  1. float4 color = _Color;
  2. cells = pow(cells + 0.5, _Power); // apply power
  3. color.rgb *= cells; // we multiply the color with the voronoi noise
  4.  
  5. float2 worldPos = _input.posWS.xz; // we only use X,Z axes
  6. // we get the length of the vector from pos to center = distance from center
  7. float distFromCenter = length(_input.posWS.xz - _Center);
  8. // step(x,y) returns 1 if y > x, so inStain = 1 if _Radius > distFromCenter
  9. // and inStain = 0 if the distance from center is bigger than the wanted radius
  10. half inStain = step(distFromCenter, _Radius);
  11. color.a = inStain;
  12.  
  13. return color;

Be sure your plane position is (0;0;0) and you will get this by playing with the Stain radius value.

We're almost done. We only need some noise to make it look more like a real stain! We can use this free seemless texture I edited.

Download it, import it in Unity, and configure it as a single channel texture.

Let's use it in our shader. Again, same ritual: add a public property, declare the variable in the HLSL code, use it in the frag function. This tutorial is already huge enough, so we also add a _NoiseScale and a _NoiseStrength values to manage our noise properly.

  1. Properties
  2. {
  3. _Color("Color", Color) = (1,1,1,1)
  4. _AngleOffset("Angle offset", Float) = 1.0
  5. _CellDensity("Cell density", Float) = 1.0
  6. _Smoothness("Smoothness", Range(0,1)) = 0.5
  7. _Power("Power", Range(0, 5)) = 1.0
  8. _Center("Stain center", Vector) = (0,0,0,0)
  9. _Radius("Stain radius", Float) = 1.0
  10. _NoiseTex("Noise texture", 2D) = "white" {} // for the texture
  11. _NoiseScale("Noise scale", Float) = 1.0 // to manage the texture's scale
  12. _NoiseStrength("Noise strength", Float) = 1.0 // strength of the effect
  13. }

  1. CBUFFER_START(UnityPerMaterial)
  2. half4 _Color;
  3. half _AngleOffset;
  4. half _CellDensity;
  5. half _Smoothness;
  6. half _Power;
  7. half2 _Center;
  8. half _Radius;
  9. TEXTURE2D(_NoiseTex); // contains the texture
  10. SAMPLER(sampler_NoiseTex); // allows us to sample the texture at given UV
  11. half _NoiseScale;
  12. half _NoiseStrength;
  13. CBUFFER_END

  1. float2 worldPos = _input.posWS.xz; // we only use X,Z axes
  2.  
  3. // sample noise texture
  4. // we could use the object UV, but here again, we are using the (X;Z) world pos instead
  5. float2 noiseUV = worldPos * _NoiseScale;
  6. float stainNoise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, noiseUV).r;
  7. // stainNoise value is in range [0;1], we want it in range [-1;1]
  8. stainNoise = stainNoise * 2 - 1;
  9. stainNoise *= _NoiseStrength; // finally, apply strength
  10.  
  11. // we get the length of the vector from pos to center = distance from center
  12. float distFromCenter = length(_input.posWS.xz - _Center) + stainNoise; // we add the noise here!
  13. // step(x,y) returns 1 if y > x, so inStain = 1 if _Radius > distFromCenter
  14. // and inStain = 0 if the distance from center is bigger than the wanted radius
  15. half inStain = step(distFromCenter, _Radius);
  16. color.a = inStain;
  17.  
  18. return color;

That's it!

Here is the final version of the Watercolor shader:

  1. Shader "Watercolor"
  2. {
  3. Properties
  4. {
  5. _Color("Color", Color) = (1,1,1,1)
  6. _AngleOffset("Angle offset", Float) = 1.0
  7. _CellDensity("Cell density", Float) = 1.0
  8. _Smoothness("Smoothness", Range(0,1)) = 0.5
  9. _Power("Power", Range(0, 5)) = 1.0
  10. _Center("Stain center", Vector) = (0,0,0,0)
  11. _Radius("Stain radius", Float) = 1.0
  12. _NoiseTex("Noise texture", 2D) = "white" {} // for the texture
  13. _NoiseScale("Noise scale", Float) = 1.0 // to manage the texture's scale
  14. _NoiseStrength("Noise strength", Float) = 1.0 // strength of the effect
  15. }
  16.  
  17. SubShader
  18. {
  19. Tags
  20. {
  21. "Queue" = "Transparent" "RenderType" = "Transparent"
  22. }
  23. LOD 100
  24.  
  25. Pass // Color
  26. {
  27. Blend SrcAlpha OneMinusSrcAlpha
  28.  
  29. HLSLPROGRAM
  30. #pragma vertex vert
  31. #pragma fragment frag
  32.  
  33. #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
  34.  
  35. struct appdata
  36. {
  37. float3 posOS : POSITION;
  38. float2 uv : TEXCOORD0;
  39. UNITY_VERTEX_INPUT_INSTANCE_ID
  40. };
  41.  
  42. struct v2f
  43. {
  44. float4 posCS : SV_POSITION;
  45. float2 uv : TEXCOORD0;
  46. float3 posWS : VAR_POS;
  47. UNITY_VERTEX_INPUT_INSTANCE_ID
  48. UNITY_VERTEX_OUTPUT_STEREO
  49. };
  50.  
  51. CBUFFER_START(UnityPerMaterial)
  52. half4 _Color;
  53. half _AngleOffset;
  54. half _CellDensity;
  55. half _Smoothness;
  56. half _Power;
  57. half2 _Center;
  58. half _Radius;
  59. TEXTURE2D(_NoiseTex); // contains the texture
  60. SAMPLER(sampler_NoiseTex); // allows us to sample the texture at given UV
  61. half _NoiseScale;
  62. half _NoiseStrength;
  63. CBUFFER_END
  64.  
  65. v2f vert(appdata _input)
  66. {
  67. v2f output;
  68.  
  69. UNITY_SETUP_INSTANCE_ID(_input);
  70. UNITY_TRANSFER_INSTANCE_ID(_input, output);
  71. UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
  72.  
  73. // output.posCS = TransformObjectToHClip(_input.posOS);
  74. output.posWS = TransformObjectToWorld(_input.posOS);
  75. output.posCS = TransformWorldToHClip(output.posWS);
  76. output.uv = _input.uv;
  77. return output;
  78. }
  79.  
  80. inline float2 GetVoronoiRandomVector(float2 _uv, float _offset)
  81. {
  82. float2x2 m = float2x2(15.27, 47.63, 99.41, 89.98);
  83. _uv = frac(sin(mul(_uv, m)) * 46839.32);
  84. return float2(sin(_uv.y * _offset) * 0.5 + 0.5, cos(_uv.x * _offset) * 0.5 + 0.5);
  85. }
  86.  
  87. void Voronoi_float(float2 _uv, float _angleOffset, float _cellDensity, float _smoothness, out float _noise, out float _cells)
  88. {
  89. float2 g = floor(_uv * _cellDensity);
  90. float2 f = frac(_uv * _cellDensity);
  91. //float3 res = float3(8.0, 0.0, 0.0);
  92.  
  93. float smoothDistance = 0.0;
  94. _cells = 0.0;
  95. _noise = 0.0;
  96. float h = -1.0;
  97.  
  98. for (int y = -1; y <= 1; y++)
  99. {
  100. for (int x = -1; x <= 1; x++)
  101. {
  102. float2 lattice = float2(x, y);
  103. float2 offset = GetVoronoiRandomVector(lattice + g, _angleOffset);
  104. float d = distance(lattice + offset, f);
  105. // if (d < res.x)
  106. // {
  107. // res = float3(d, offset.x, offset.y);
  108. // _noise = res.x;
  109. // _cells = res.y;
  110. // }
  111.  
  112. h = h == -1.0f ? 1.0 : smoothstep(0.0, 1.0, 0.5 + 0.5 * (smoothDistance - d) / _smoothness);
  113. float correctionFactor = _smoothness * h * (1.0 - h);
  114. smoothDistance = lerp(smoothDistance, d, h) - correctionFactor;
  115. correctionFactor /= 1.0 + 3.0 * _smoothness;
  116.  
  117. float cell = offset.x;
  118. _cells = lerp(_cells, cell, h) - correctionFactor;
  119.  
  120. float pos = d;
  121. _noise = lerp(_noise, pos, h) - correctionFactor;
  122. }
  123. }
  124. }
  125.  
  126. half4 frag(v2f _input) : SV_Target
  127. {
  128. UNITY_SETUP_INSTANCE_ID(_input);
  129. UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(_input);
  130.  
  131. float noise = 0.0, cells = 0.0; // we declare values to store voronoi results
  132. Voronoi_float(_input.uv, _AngleOffset, _CellDensity, _Smoothness, noise, cells); // Voronoi's magic
  133.  
  134. float4 color = _Color;
  135. cells = pow(cells + 0.5, _Power); // apply power
  136. color.rgb *= cells; // we multiply the color with the voronoi noise
  137.  
  138. float2 worldPos = _input.posWS.xz; // we only use X,Z axes
  139.  
  140. // sample noise texture
  141. // we could use the object UV, but here again, we are using the (X;Z) world pos instead
  142. float2 noiseUV = worldPos * _NoiseScale;
  143. float stainNoise = SAMPLE_TEXTURE2D(_NoiseTex, sampler_NoiseTex, noiseUV).r;
  144. // stainNoise value is in range [0;1], we want it in range [-1;1]
  145. stainNoise = stainNoise * 2 - 1;
  146. stainNoise *= _NoiseStrength; // finally, apply strength
  147.  
  148. // we get the length of the vector from pos to center = distance from center
  149. float distFromCenter = length(_input.posWS.xz - _Center) + stainNoise; // we add the noise here!
  150. // step(x,y) returns 1 if y > x, so inStain = 1 if _Radius > distFromCenter
  151. // and inStain = 0 if the distance from center is bigger than the wanted radius
  152. half inStain = step(distFromCenter, _Radius);
  153. color.a = inStain;
  154.  
  155. return color;
  156. }
  157. ENDHLSL
  158. }
  159. }
  160. }

the paper effect

Last but not least, we can simulate a paper effect, by simply using a build-in RendererFeature provided by Unity: the FullScreenPassRendererFeature. Select the Renderer Data currently used by your Render Pipeline Asset, and add a FullScreenPassRendererFeature.

This renderer feature needs a material to apply an effect over the whole screen. Per default, it adds a material that inverts colors. Instead, we'll put a material that add the grainy paper texture. To do so, we need to create an other shader we'll apply on a new material.

- create a 'paper' shader (Assets > Create > Shader > Unlit shader),
- create a material (right-click on the paper shader > Create > Material).

Open the shader and replace everything by this:

  1. Shader "Paper"
  2. {
  3. Properties
  4. {
  5. _PaperTexture("Texture", 2D) = "white" {}
  6. _Power("Power", Range(0.001, 3)) = 1.0
  7. }
  8.  
  9. SubShader
  10. {
  11. Tags
  12. {
  13. "RenderType"="Opaque"
  14. }
  15.  
  16. Pass
  17. {
  18. HLSLPROGRAM
  19. #pragma vertex vert
  20. #pragma fragment frag
  21.  
  22. #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
  23.  
  24. struct appdata
  25. {
  26. uint vertexID : SV_VertexID;
  27. };
  28.  
  29. struct v2f
  30. {
  31. float4 posCS : SV_POSITION;
  32. float2 uv : TEXCOORD0;
  33. float3 posWS : VAR_POS;
  34. };
  35.  
  36. TEXTURE2D(_BlitTexture); // Screen texture
  37. SAMPLER(sampler_BlitTexture);
  38.  
  39. TEXTURE2D(_PaperTexture); // Paper texture
  40. SAMPLER(sampler_PaperTexture);
  41. half4 _PaperTexture_ST;
  42. half _Power; // Effect power
  43.  
  44. v2f vert(appdata _input)
  45. {
  46. v2f output;
  47.  
  48. output.posCS = GetFullScreenTriangleVertexPosition(_input.vertexID);
  49. output.uv = GetFullScreenTriangleTexCoord(_input.vertexID);
  50.  
  51. return output;
  52. }
  53.  
  54. half4 frag(v2f _input) : SV_Target
  55. {
  56. // We get the screen render at this pixel
  57. half4 render = SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, _input.uv);
  58.  
  59. // We get the paper effect at this pixel
  60. half3 paper = SAMPLE_TEXTURE2D(_PaperTexture, sampler_PaperTexture, _input.uv.xy * _PaperTexture_ST.xy + _PaperTexture_ST.zw).rgb;
  61. paper = pow(paper, _Power);
  62.  
  63. // We apply the paper effect on the screen render
  64. render.rgb *= paper;
  65. return render;
  66. }
  67. ENDHLSL
  68. }
  69. }
  70. }

Then, set the paper material to the renderer feature.

Nothing happens, because we didn't set a paper texture yet. Let's do it. I took a free paper texture from TextureLab. You can take a look, I took this one.

Download the image, import it in Unity, and drop it in the Texture field of the paper material. And voilĂ , the paper effect is on!

I set a bigger Power value to make it a bit more visible
What next?

This tutorial is just a solid foundation for achieving a cool watercolor effect. The result can be improved in many ways!
- the stain borders are a bit sharp, we could smooth it by using something else than a step function for instance.
- we could use a Triplanar UV for the voronoi, to apply it on non-plane surfaces (even if we still using world position instead of the object UV)
- we could blend different colors, add noise to the voronoi pattern...
- we can merge this effect with outline
- we can take control of the material parameters in our scripts, to make the stain appear or disappear, to change its color...

I hope you enjoyed this tutorial and learned something new!
If you have any question, don't hesitate to ask. You can follow me and/or contact me on Bluesky!

website handcrafted by me (I know, you can tell, thank you)