Trafiquer un shader

27 août 2018

N’importe quel programmeur sain d’esprit enseignerait “Lisez la documentation, comprenez ce que vous faites, et vous serez capable de résoudre vos problèmes”. Tout à fait vrai ! Mais il y a un défaut : plus le sujet est vaste ou profond, plus le temps nécessaire à son apprentissage est grand. Parfois, “hacker” son chemin pourrait suffire. Et puis la vie est plus amusante avec un brin de folie non ? Trafiquons les shaders !

Un pas en arrière

Ok oui il y a des choses que vous devez savoir avant de plonger dans les shaders, les voici :

  1. Un shader contient 3 parties importantes : les déclarations, la fonction vertex et la fonction fragment.

    • Les déclarations vous permettent de piloter ou de configurer le shader à partir d’un script.
    • La fonction vertex vous permet de manipuler le monde 3D (déplacement de vectrices).
    • La fonction fragment vous permet de manipuler les pixels rendus à l’écran.
  2. Une couleur est représentée par un vecteur à 4 dimensions fixed4.
  3. Dans la fonction fragment, vous ne manipulez qu’un seul pixel à la fois.
  4. La fonction frac(float) renvoie la partie décimale d’un nombre à virgule flottante.
  5. text2D(sampler2D,float2) donne la couleur des pixels de la texture sample2D à la coordonnée float2. Vous obtenez cette coordonnée en paramètre d’entrée dans la fonction fragment.
  6. Les coordonnées sont comprises entre (0,0) et (1,1).

Ça peut sembler très spécifique si les shaders sont nouveaux pour vous, mais ce sont en fait des choses que vous trouveriez tôt dans votre apprentissage des shaders.

Plusieurs couches de rendu

Je travaille sur un jeu appelé A Time Paradox qui se déroule dans un univers futuriste (dans un vaisseau spatial chevauchant un horizon de trou noir). Une façon d’évoquer un monde futuriste est d’utiliser quelque chose de typique, attrayant et rapide à créer : des hologrammes.

Voici l’idée : nous allons afficher une texture de projecteur (spotlight) en arrière-plan, puis animer une texture de bruit (glitch) sur le dessus pour simuler des lignes de balayage. Le plan ? Trouver un shader 2D assez simple comme point de départ, puis ajouter une texture de glitch par dessus la texture principale. Enfin, s’amuser avec des maths pour animer la texture de glitch.

En avant !

Shader de départ

Note : J’utilise Unity et paint.NET comme outils mais la façon de faire fonctionnerait pour n’importe quel moteur ou technologie supportant les shaders et n’importe quel éditeur d’images.

Sur https://unity3d.com/fr/get-unity/download/archive vous trouverez sous “téléchargements (votre plate-forme)” une entrée nommée “shaders intégrés”. Il contient tous les shaders de Unity et ils peuvent être copié/collé/modifié dans votre projet. J’ai choisi de commencer avec le shader DefaultResourcesExtra\Unlit\Unlit-Alpha.shader puisque je travaille en 2D sans éclairage et avec transparence. Il ressemble à ça :

// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)

// Unlit alpha-blended shader.
// - no lighting
// - no lightmap support
// - no per-material color

Shader "Unlit/Transparent" {
Properties {
    _MainTex ("Base (RGB) Trans (A)", 2D) = "white" {}
}

SubShader {
    Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
    LOD 100

    ZWrite Off
    Blend SrcAlpha OneMinusSrcAlpha

    Pass {
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata_t {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f {
                float4 vertex : SV_POSITION;
                float2 texcoord : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                UNITY_VERTEX_OUTPUT_STEREO
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata_t v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.texcoord);
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
        ENDCG
    }
}}

On trouve ici les “Properties”, ainsi que les fonctions “vert” et “frag”. La fonction frag fait globalement la chose suivante : rechercher la couleur du pixel dans _MainTex qui est situé à la position i.texcoord, exécuter un processus UNITY_APPLY_FOG_FOG que nous ne connaissons pas, retourner la couleur.

Avant tout, faisons fonctionner ce shader :

  1. copier/coller le fichier shader dans le dossier assets du projet;
  2. dans le fichier shader ligne 8, renommer le shader de Shader Unlit/Transparent en Shader Custom/MyHologram ;
  3. dans l’éditeur de Unity, cliquez avec le bouton droit de la souris sur le fichier shader -> create -> material pour créer un nouveau material associé au shader ;
  4. créer (voir ci-dessous), puis glisser-déposer le sprite “spotlight” depuis les assets vers la scène ;
  5. glisser-déposer le material du dossier asset vers le sprite dans la hiérarchie de la scène pour que le sprite utilise le material configuré avec notre shader.

Le sprite “spotlight” ? Utilisons celui là :

Cela devrait donner le résultat suivant : Unity screenshot

Ajouter une texture de glitch

Ajoutons une nouvelle texture de glitch. Nous pourrions soit en trouver une en ligne, soit la créer nous-même. Voici comment j’ai créé la mienne :

  1. Créez un fichier png 1024x1024 entièrement noir.
  2. Sur un nouveau calque, tracez au hasard des lignes blanches horizontales de 3 pixels de hauteur.
  3. Dupliquer le dernier calque créé, appliquer un “effet de flou de mouvement” vers le haut de 10px
  4. Répétez l’étape 3. quelques fois jusqu’à avoir un dégradé lisse.
  5. Enlever la couche de ligne d’origine (le blanc pur est trop dur et tranchant).
  6. Fusionner toutes les couches restantes.

Vous devriez obtenir quelque chose comme ça (j’ai aussi un peu modifié la courbe de luminosité) : scanlines

Pour l’utiliser dans notre shader, recopions ce qui est fait pour le _MainText.

Ligne 11, ajoutons la propriété _GlitchTex :

Properties {
    _MainTex ("Base (RGB) Trans (A)", 2D) = "white" {}
    _GlitchTex ("Glitch Texture", 2D) = "white" {}
}

Puis ligne 44, ajouter la déclaration _GlitchTex :

sampler2D _MainTex;
sampler2D _GlitchTex;
float4 _MainTex_ST;

A partir de ce point, la propriété “Glitch texture” devrait apparaître dans l’inspecteur du sprite “spotlight” dans Unity. Sélectionnez la texture que vous venez de créer (ou utilisez la mienne) comme valeur.

Et… il ne se passe rien. En effet, nous devons maintenant utiliser au niveau du rendu la texture que nous avons déclarée dans le code du shader. Que les maths commencent !

Maths et fun

Pour changer le rendu des pixels, la fonction fragment est celle que nous devons modifier :

fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.texcoord);
    UNITY_APPLY_FOG(i.fogCoord, col);
    return col;
}

L’objectif est d’afficher la couleur de notre texture “glitch”, essayons de copier le code utilisé pour _MainTex et de mélanger les 2 couleurs.

    fixed4 mainCol = tex2D(_MainTex, i.texcoord);
    fixed4 glitchCol = tex2D(_GlitchTex, i.texcoord);
    fixed4 col = mainCol + glitchCol;

test1

Super, nous avons mélangé les 2 textures en 1 rendu ! Mais ça a l’air bizarre, n’est-ce pas ? L’alpha de la première image semble ne plus fonctionner : le dégradé lisse que nous avions est perdu et les bords ont l’air faux. Essayons de multiplier au lieu d’additionner pour mélanger les couleurs.

    fixed4 mainCol = tex2D(_MainTex, i.texcoord);
    fixed4 glitchCol = tex2D(_GlitchTex, i.texcoord);
    fixed4 col = mainCol * glitchCol;

test2

Pas mal, mais on ne reconnait pas le sprite d’origine. Cela dit, on pourrait utiliser ce résultat comme “couche supplémentaire” non ? Additionnons ce rendu (mainCol * glitchCol) avec la couleur d’origine (mainCol).

    fixed4 mainCol = tex2D(_MainTex, i.texcoord);
    fixed4 glitchCol = tex2D(_GlitchTex, i.texcoord);
    fixed4 col = mainCol * glitchCol + mainCol;

test3

Hé ! On s’approche !

Maintenant, il est temps d’animer le glitch. Pour cela, nous aurons besoin d’une sorte de variable basée sur le temps. Chaque moteur fournit un moyen d’accéder à une horloge, Unity fournit _Time.y. L’idée est de modifier le texcoord (rappel : c’est un vector2 entre 0,0 et 1,1 pour les coordonnées x,y), pour que la valeur y soit modifiée dans le temps et bouclée entre 0 et 1 pour faire un défilement infini.

Pour boucler une variable qui dépend du temps entre 0 et 1, nous pouvons faire comme suit :

    fixed scrollValue = frac(0.04 * _Time.y);

_Time.y est une valeur qui augmente indéfiniement (temps écoulé en secondes depuis de début du jeu), frac() ne conserve que la partie décimale d’un nombre à virgule flottante (faisant boucler la valeur entre 0 et 1), 0.04 est une constante arbitraire utilisée comme ratio pour ralentir l’effet de défilement. Cette constante pourrait être extraite comme propriété du shader pour configurer la vitesse, mais gardons-la inline pour le moment.

Afin de remapper la position statique de texcoord dans sa version “scrollée”, nous devons créer un nouveau texcoord float2, et changer sa valeur y pour appliquer le défilement.

    float2 offsetTexCoord = float2(
        i.texcoord.x,
        frac(i.texcoord.y + 0.04 * _Time.y)
    );
    fixed4 glitchCol = tex2D(_GlitchTex, offsetTexCoord);

Ici, on conserve la valeur x tel quel, et on ajoute notre valeur de scroll qui varie en fonction du temps. Ça indique au shader, pour un pixel donné, de regarder une position verticale différente à l’intérieur de la texture au fil du temps. Parce que cette position verticale se déplace lentement, elle rend la texture “décalée”. Et c’est parti !

Remarque: j’ai triché un peu pour obtenir une boucle d’animation parfaite, votre hologramme devrait se déplacer un peu moins vite.

We need to go deeper (Nous devons aller plus loin)

Le rendu est déjà bien, mais notre machine à reconnaissance de motifs (notre cerveau) trouvera rapidement la répétition et perdra rapidement son intérêt pour le visuel. Peu importe, nous avons plus d’un tour dans notre sac ! On pourra par exemple utiliser plusieurs couches de glitch qui défilent à des vitesses différentes. Dans l’idée, on copie/colle le même code que précédemment mais avec des décalages différents pour extraire les couleurs à des positions différentes dans le temps.

fixed4 mainCol = tex2D(_MainTex, i.texcoord);

// glitch 1
float2 offsetTexCoord = float2(
    i.texcoord.x,
    frac(i.texcoord.y + _Time.y * 0.33)
);
fixed4 glitchCol = tex2D(_GlitchTex, offsetTexCoord);
// glitch 2
float2 offsetTexCoord2 = float2(
    i.texcoord.x,
    frac(i.texcoord.y +  _Time.y * 0.4521)
);
fixed4 glitchCol2 = tex2D(_GlitchTex, offsetTexCoord2);
// glitch 3
float2 offsetTexCoord3 = float2(
    i.texcoord.x,
    frac(i.texcoord.y + _Time.y * 0.2541)
);
fixed4 glitchCol3 = tex2D(_GlitchTex, offsetTexCoord3);

fixed4 col = mainCol + mainCol * glitchCol * 0.4 + mainCol * glitchCol2 * 0.2 + mainCol * glitchCol3 * 0.4;

Le résultat :

Ici le shader va lire dans la texture à 3 positions différentes, chacune de ces positions défilant à une vitesse différente. Ensuite, on additionne toutes les couleurs obtenues mais en appliquant une constante pour adoucir le rendu (sinon le rendu est trop lumineux). Autre conséquence, le rendu animé ne peut plus boucler de façon fluide comme avant, car il est extrêmement rare que les 3 couches de glitch retrouvent leur position initiale au même moment. Ainsi, dans le jeu, is smeble que l’animation ne se répête jamais ce qui la rends plus intéressante à regarder.

We need to go deeper AGAIN (Nous devons aller ENCORE plus loin)

On pourrait s’arrêter là, mais mon cerveau n’est pas encore satisfait. On observe encore certains motifs, il y a mieux à faire.

L’idée en bref : plutôt que d’additionner les couleurs des 3 couches, multiplions les ! En effet, il y a toujours une valeur lumineuse “minimale” (qu’on visualise comme une bande blanche) que l’œil peut suivre le long du chemin et qui se répétera. Le fait que les lignes se croisent rend le suivi plus difficile mais pas impossible.
Alors qu’en multipliant les 3 valeurs, parfois les lignes se chevaucheront et créeront des lignes plus brillantes, et parfois elles s’éloigneront et les lignes disparaitront en fondu.

Niveau maths le changement est assez direct :

fixed4 col = mainCol + mainCol * glitchCol * glitchCol2 * glitchCol3;

Par contre il est nécessaire de revoir la texutre pour qu’elle soutienne l’idée. Pour que le rendu soit intéressant il faut que les lignes se superposent plus souvent (lignes plus larges, luminosité plus claire). En repartant de la texture précédente, j’ai inversé les couleurs puis manipulé la courbe de luminosité jusqu’à ce que le rendu me plaise.

lightdust2

Et voilà le résultat final :

Comme vous pouvez le voir, parfois les lignes apparaissent puis s’estompent. Si vous êtes arrivés jusque là dans votre implémentation vous pouvez constater que les lignes apparaissent et disparaissent à des endroits qui semblent toujours différents, et on peut l’observer pendant des heures sans trouver de répétition dans les motifs. L’effet est maintenant plus subtil, plus intrigant !

Mots de la fin

Merci de m’avoir suivi jusque là ! Je travaille dur sur A Time Paradox pour que le rendu soit cool ! Passez voir le site !

Vous souhaitez régir ? Vous avez fait des choses grace à cet article ? dites-le moi !
Envoyez vos messages privés et mentions sur twitter @Lythom, et les messages à samuel@a-game-studio.com. Je les retweeterai !

Bon hacking !


Samuel Bouchet
Écrit par Samuel Bouchet à Nantes.