shader - effet aquarelle
résultat recherché
Une tâche d'aquarelle contrôlable!
le setup

Pour ce tutoriel, j'utilise Unity 6. Notez que tout ce qu'on va faire ici devrait fonctionner sur d'anciennes versions, car nous n'allons pas utiliser de fonctionalité spécifique à Unity 6. Cependant, certaines choses sont peut-être nommées différemment, notamment ce qui concerne les RendererFeatures.

Car oui, j'utilise l'URP, pour avoir accès aux RendererFeatures, qui vont être utiles pour l'effet papier granuleux. Ce tutoriel est basé sur la création de shaders en HLSL. Donc si vous n'êtes pas à l'aise avec ça, je vous encourage à suivre attentivement ce tutoriel pour bien comprendre la logique derrière ! Je ne vais pas tout expliquer à propos des shaders ici, mais je vais essayer d'être le plus clair possible sur les étapes pour atteindre cette effet aquarelle.

Commençons par mettre en place une scene dans Unity:
- ajoutez un plan (GameObject > 3D Object > Plane)
- créez un shader nommé "watercolor" (Assets > Create > Shader > Unlit shader)
- créez un material (right-click on the watercolor shader > Create > Material)
- appliquez le material au plan.

Ouvrons maintenant le shader, et remplacez son contenu par ceci:

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

Ce shader est très basique et permet seulement d'appliquer une couleur au material. Une fois de plus, je ne vais pas expliquer pleinement son fonctionnement, mais j'ai ajouté des commentaires pour aider.

la texture d'aquarelle

Maintenant, nous pouvons créer notre shader. Je me suis inspiré de l'incroyable tutoriel de Kai sur Youtube.
Voici les étapes que nous allons traverser :
- nous allons implémenter le pattern Voronoi pour avoir des variations de couleur à la surface,
- on appliquera un lissage (smoothness) au résultat du pattern Voronoi pour adoucir les variations de couleur,
- on utilisera un rayon pour créer une tâche sur notre surface.

Commençons par la première étape. Pour obtenir l'algorithme du Voronoi pattern, j'ai simplement vérifié le code du node de Voronoi de Unity. Si vous souhaitez plus d'informations sur le Voronoi, n'hésitez pas à consulter la page Wikipedia.
Quoi qu'il en soit, voici le résultat que l'on peut intégrer au shader, au-dessus de la fonction frag.

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

Comme vous pouvez le voir, il y a deux fonctions : 'GetVoronoiRandomVector' et 'Voronoi_float'. GetVoronoiRandomVector est appelé par Voronoi_float, et c'est bien Voronoi_float que nous allons appeler dans notre fonction frag pour appliquer le pattern Voronoi.

La fonction Voronoi_float a plusieurs paramètres :
- un float2 '_uv', un float '_angleOffset' et un float '_cellDensity' que nous devons fournir,
- un float '_noise' et un float '_cells' qui contiennent le résultat.

Pour commencer, nous allons fournir les UV de l'objet. Pour ce faire, nous avons besoin de récupérer les UV à partir de la structure appdata. Et nous pourrons les rendre utilisables dans le fragment shader (notre fonction frag) à travers la structure v2f.

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

Dans la fonction vert, nous devons envoyer la valeur d'UV dans l'instance v2f "ouput".

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

Ensuite, nous allons fournir un "angle offset" et la "cell density" à travers deux paramètres. Nous les déclarons dans la section Properties, pour être en mesure de pouvoir définir leur valeur dans l'Inspector de Unity. Et nous les déclarons également dans la code HLSL pour les utiliser dans la fonction frag.

  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

Maintenant, nous avons tout à notre disposition pour la fonction de Voronoi. Utilisons-là !

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

Retour sur Unity. Vous pouvez selectionner le material "Watercolor", appliquer - par exemple - une Cell density de 5, et jouer avec la valeur d'Angle offset pour voir le résultat.

Pour le moment, nous ne souhaitons pas avoir de transparence. Nous en avons car nous multiplions l'alpha de la couleur par le resultat du Voronoi pattern. Pour empêcher ça, nous pouvons explicitement demander de ne multiplier que les valeurs rgb de la couleur.

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

Egalement, pour notre effet, nous n'avons pas besoin du noise, mais des cells du Voronoi pattern. La valeur de cells est comprise entre 0 et 1, nous pouvons donc ajouter 0.5, pour obtenir une valeur entre 0.5 et 1.5, pour obtenir une variation de couleur proportionnellement autour de la valeur donnée dans le material, plutôt que des couleurs uniquement plus sombres.

  1. color.rgb *= cells + 0.5;

Maintenant, nouvelle étape : nous devons lisser les cells du Voronoi pattern, pour simuler la variation de couleur correctement. Dans son tutoriel, Kai active simplement l'option "smooth" sur le node Voronoi de Blender, ce que nous n'avons évidemment pas sous la main. J'ai donc vérifié directement le code source sur le git de Blender pour implementer la bonne logique dans notre fonction Voronoi_float. Voici le résultat:

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

Comme vous pouvez le voir, nous avons un paramètre "_smoothness" à founir. Ajoutons donc un paramètre _Smoothness à notre 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. }

On le déclare également dans le code HLSL.

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

Et enfin, on le fournit à la fonction Voronoi_float, dans la fonction frag.

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

Nos cellules sont maintenant lissées. On peut également ajouter une variable _Power pour contrôler la puissance de variation des couleurs. Le rituel reste le même : ajouter une propriété _Power, la déclarer dans le code HLSL, puis l'utiliser dans la fonction frag.

  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;

Grâce à cela, nous pouvons jouer avec Smoothness et Power du material pour obtenir la variation de couleur que l'on recherche (et n'oubliez pas que vous pouvez également jouer avec la Cell density pour changer la taille des cellules, ou avec l'Angle offset pour changer la forme des cellules).

l'effet tâche

Pour l'effet de tâche, nous avons besoin d'un centre et d'un rayon.

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

Comme à chaque fois avec les shaders, il y a plusieurs manières de faire les choses. Les choix dépendent de nos besoins. Ici, nous avons un plan avec l'axe Y pour normale. Donc, plutôt que d'utiliser les UV de l'objet, nous pouvons utiliser la position en X et en Z de l'objet dans le monde (world space).
Pour ce faire, nous devons enregistrer la position dans le monde dans la structure v2f, pour l'utiliser dans le fragment shader (= dans notre fonction frag). D'abord, on ajoute posWS dans la structure v2f.

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

Ensuite, ne devons affecter la variable pendant le vertex shader (= notre fonction vert).
Au lieu d'utiliser la fonction TransformObjectToHClip, qui nous donne directement la position sur l'écran (clip space) à partir de la position du vertex dans l'objet (object space), nous utiliserons :
- la fonction TransformObjectToWorld pour obtenir la position du vertex dans le monde (world space),
- puis la fonction TransformWorldToHClip pour obtenir la position du vertex sur l'écran.

Notez que nous pourrions simplement utiliser la fonction TransformObjectToWorld à côté de la fonction TransformObjectToHClip. Mais comme TransformObjectToHClip est grosso-modo un mix de TransformObjectToWorld + TransformWorldToHClip, il est meilleur pour les performances de procéder ainsi :

  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;

Nous pouvons à présent utiliser posWS dans la fonction frag. Nous devons vérifier la position du fragment/pixel dessiné dans le monde, et obtenir sa distance par rapport au _Center. Si la distance est supérieure à la valeur de _Radius, alors nous sommes en dehors de la tâche, et nous mettons l'alpha à 0.
D'abord, nous devons déclarer _Center et _Radius dans le code HLSL.

  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

Ajoutons la logique pour obtenir une tâche ! (consultez les commentaires pour quelques explications supplémentaires)

  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;

Faites bien attention à ce que votre plan soit bien placé en (0;0;0) et vous obtiendrez ceci en jouant avec la valeur de Stain radius.

Nous avons presque terminé. Nous avons juste besoin d'ajouter du bruit pour que ça ressemble plus à une vraie tâche ! Nous pouvons utiliser cette texture libre et seemless que j'ai éditée.

Téléchargez-là, importez-là dans Unity, et configurez-là comme une texture "single channel".

Utilisons-là dans notre shader. Encore, même rituel : on ajoute une propriété publique, on déclare la variable dans le code HLSL, et on l'utilise dans notre fonction frag. Ce tutoriel est déjà bien assez long, on va donc aussi ajouter deux valeurs : _NoiseScale et _NoiseStrength pour gérer notre effet de bruit.

  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;

Et voilà !

Voici la version finale du shader d'aquarelle :

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

l'effet paper granuleux

Le dernier mais pas des moindres, nous pouvons simuler un effet papier, en utilisant une RendererFeature fourni par Unity : la FullScreenPassRendererFeature. Selectionnez votre Renderer Data actuellement utilisé par votre Render Pipeline Asset, et ajoutez lui un FullScreenPassRendererFeature.

Cette renderer feature utilise un material qu'il applique sur l'entièreté de l'écran. Par défaut, il utilise un material qui inverse les couleurs.
A la place, on va mettre un material qui ajoute l'effet papier granuleux. Pour ça, nous créons un autre shader, que l'on va appliquer sur un nouveau material.

- créez un shader nommé 'paper' (Assets > Create > Shader > Unlit shader),
- créez un material (right-click on the paper shader > Create > Material)

Ouvrez le shader et remplacez son contenu par ceci :

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

Ensuite, appliquez ce nouveau material à la renderer feature.

Rien ne se passe, et c'est normal : nous n'avons pas appliqué de texture au material. Ici, j'ai pris une texture libre de droit sur TextureLab. Vous pouvez regarder si vous trouvez quelque chose à votre goût. Pour ma part, j'ai choisi celle-ci.

Téléchargez l'image, importez-là dans Unity, and appliquez-là au champ Texture de votre material paper. Et voilà ! L'effet papier est appliqué !

J'ai mis une valeur Power assez elevée pour qu'on le voit mieux
Et ensuite ?

Ce tutoriel est juste une base solide pour obtenir un chouette effet d'aquarelle. Le résultat peut être amélioré de plein de manières !
- les bordures de la tâche sont un peu abruptes, et peuvent être lissée
- on pourrait utiliser des UV triplanar pour le Voronoi pattern, pour l'appliquer sur autre chose que des surfaces planes
- on pourrait mixer différentes couleurs
- on peut ajouter un effet d'outline
- on peut contrôler les paramètres du material via des scripts, pour faire apparaître ou disparaître la tâche, la changer de couleur...

J'espère que vous avez apprécié ce tutoriel et qu'il vous aura appris de nouvelles choses !
Si vous avez des questions, n'hésitez pas. Vous pouvez me les poser (et me suivre) sur Bluesky !

site créé par mes petites mains (oui ça se voit, je sais, merci)