Comment réaliser un Cel Shading ?

thumbnail-comment-realiser-un-cel-shading-presque-parfait

Le Cel Shading ou « ombrage de celluloïd » (oui, en français c’est moche ! Continuons avec le terme anglais pour la suite) est un shader qui donne un effet toon 2D à un modèle 3D.

Un shader est un programme exécuté par la carte graphique pour modifier le processus de rendu d’une application.

Dans ce tutoriel, nous développerons un Cel Shading à la fois sur Blender et Unity.

Le Cel Shading est particulièrement répandu dans l’industrie vidéo-ludique. De nombreux jeux (pour ne pas dire les meilleurs) utilisent ce procédé.

Avec ses couleurs vives et mattes, ce style a pour qualités d’être plutôt agréable à regarder et de très bien vieillir dans le temps.

The Wind Waker Cel Shading
The Wind Waker date déjà de 2003 !

De plus, le Cel Shading s’adapte particulièrement bien aux jeux vidéo. Il est relativement facile à réaliser et maintient de bonnes performances. Il s’accommode aussi bien à un jeu enfantin qu’à un jeu mature.

borderlands-2-Cel-Shading
Borderlands 2 – PEGI 18

Le Cel Shading permet d’offrir un rendu 2D à partir de modèles 3D comme l’explique très bien cette vidéo GDC de Guilty Gear Xrd. C’est une des nombreuses techniques 2,5D.

La 2,5D

La 2,5D est un mélange de techniques à la fois 2D et 3D. Elle peut utiliser des sprites 2D pour donner un rendu 3D, et réciproquement, utiliser des modèles 3D pour donner un rendu 2D. Le Cel Shading n’est qu’une technique 2,5D parmi d’autres.

La 2,5D est beaucoup utilisée dans les jeux vidéo, notamment pour des raisons artistiques et de performance.

Sur la Mega Drive par exemple, la 3D (au sens interprété par les programmeurs) n’était pas possible techniquement.

Les jeux étaient entièrement réalisés en 2D mais certains développeurs créatifs ont rusé, en feintant la 3ème dimension avec des subterfuges d’animation, donnant ainsi un semblant de réalisme.

Outrun_effet_hauteur-3D
Outrun sur Mega Drive. Remarquez l’effet de hauteur.

Mais finalement, que l’on parle de 2D ou de 3D, c’est globalement la même chose lorsque l’on met les mains dans le cambouis. La barrière entre les deux définitions techniques est de plus en plus floue à mesure que l’on creuse dans le code des moteurs graphiques.

Le rendu 3D n’est qu’un ensemble de techniques d’imagerie 2D donnant l’illusion de la profondeur et n’est-ce pas le but des moteurs 3D que de retransmettre la perspective sur une surface de pixels 2D ?

Étude du Cel Shading

Lorsque l’on compare un matériau unlit (non-éclairé) avec un matériau appliquant du Cel Shading, on remarque deux modifications :

  • L’ombrage est délimité brusquement, sans dégradé, avec 2 ou 3 couleurs flat (parfois une texture terne).
  • Un contour est souvent ajouté au modèle. La pression du tracé peut varier pour donner encore plus l’illusion d’un effet « fait main« .
unlit vs cel shading

Il s’oppose donc au soft shade qui cherche à adoucir la frontière entre les zones éclairées/ombrées pour plus de réalisme.

soft shade vs cel shading

Voici le rendu du shader que nous allons développer. J’ai spécialement modélisé un chien en low poly pour ce tuto :

Cel-shading doge dogo
Le plus beau chien du Monde. Sans objection.

Nous développerons ces deux effets, l’ombrage et le contour, de manière indépendante. Mais avant d’entrer dans les détails, une petite étude sur les normales s’impose…

Normales et Backface culling

En mathématiques, et plus précisément en géométrie, la droite normale à une surface en un point est la droite orthogonale au plan tangent en ce point. Tout vecteur directeur de cette droite est appelé vecteur normal à la surface en ce point.

(merci wikipedia)

Derrière ce charabia vocabulaire mathématique se cache un procédé relativement simple à comprendre : c’est la définition de l’orientation d’une face d’un polygone.

Dans les logiciels 3D, les normales sont des informations géométriques contenues directement dans les modèles 3D (et non pas dans le matériau ou dans la texture appliquée à l’objet).

Blender vecteur normal
Représentation d’un vecteur normal sous Blender. (J’ai rajouté le sens des vecteurs.)

En Mathématique, un polygone contient deux faces. Chaque face n’est visible que d’un côté.

Ce n’est pas le cas dans les logiciels 3D où un polygone ne contient qu’une seule face.

Le back-face culling (ou l’élimination des faces cachées) est le procédé d’optimisation graphique à l’origine de ce phénomène. Le back-face culling détermine la face d’un polygone.

En pratique, puisque un polygone convexe ne possède qu’une seule normale pour deux faces, on détermine la face à afficher selon si le sens du vecteur normal pointe vers la caméra.

Ainsi, le polygone est invisible lorsque la caméra regarde dans le même sens que le vecteur normal et visible lorsque la caméra regarde dans le sens opposé au vecteur normal.

Cette méthode est très utile pour les performances car le programme affiche deux fois moins de faces !

Back-face culling
Seule une face des polygones sont visibles

On peut également s’en servir pour éliminer des problèmes de caméra, en la plaçant derrière les murs !

Backface culling caméra derrière murs

Ce procédé peut cependant poser problème aux néophytes de Unity : étant activé par défaut, comment afficher un objet recto-verso comme une cape, un mur fin ou des mèches de cheveux par exemple ?

La solution la plus performante pour visualiser l’autre face est… tout simplement de dupliquer le polygone et de retourner sa normale !

Polygones dupliqués back-face culling
Le back-face culling est activé sur les deux modèles

Il est également possible de créer un shader qui viendra désactiver le culling sur le modèle avec la commande Cull off mais cette solution est souvent déconseillée pour des raisons à la fois artistiques et de performance. Elle éclaire incorrectement les faces tout en doublant le nombre de polygones à dessiner sur l’ensemble du modèle.

La première solution est préférable car elle permet une certaine flexibilité avec les polygones que l’on souhaite doubler sans provoquer d’effets graphiques indésirables.

Cel Shading 1 : L’ombrage

Commençons par définir le matériau avant de développer le shader.

Le matériau est configuré avec un niveau de diffuse très haut (près de 100%) pour obtenir un effet mat et très peu de specular, voir pas du tout, pour ne pas réfléchir la lumière. Le matériau paramétré ainsi s’apparente à un matériau unlit :

matériau shading
Le matériau configuré ainsi sous Blender est similaire à celui utilisé par les personnages de World of Warcraft ou de League of Legends.

En appliquant ce matériau à un modèle texturé avec un shader extrêmement simple (comprenez qui ne gère pas la lumière) on obtient un résultat plutôt satisfaisant :


Ajout de contours noirs au modèle sans l’ombrage précédent

Toujours le même type de matériau unlit sans Cel Shading mais avec une texture et un modèle un peu plus travaillé, voici ce que l’on peut obtenir :


Ajout de contours noirs au modèle sans l’ombrage précédent

Place aux shaders maintenant !

Sur Blender, j’utilise la node view avec le rendu « Blender render » par défaut.

Sur Unity, malheureusement le système shaderGraph proposé dans la version 2018 ne permet pas d’accomplir un Cel Shading satisfaisant. Je l’ai donc développé classiquement, au clavier.

Attention ! Blender et Unity interprètent les matériaux de manière différente. C’est-à-dire qu’il n’est pas possible d’exporter un shader développé sur Blender pour l’importer dans Unity. Pour obtenir un Cel Shading à la fois sur les deux logiciels, il faut les développer séparément. De manière générale, seules les informations relatives au modèle (c’est-à-dire, la position des vertexes, les normales, l’UV map…) et les animations sont compatibles entre Blender et Unity.

Souvent, la zone délimitant la partie ombrée de la partie éclairée est déterminée lorsque le vecteur lumineux est orthogonal avec un vecteur normal du modèle. (En gros, lorsque les 2 vecteurs « forment un angle droit »). Un produit scalaire nul nous permet donc de déterminer cette frontière d’ombrage.

Produit scalaire ndotl
Produit scalaire ndotl

Cependant, rien ne nous empêche de délimiter les zones comme on le souhaite. Avec le produit scalaire, il est possible de déterminer une valeur à partir de laquelle on souhaite délimiter la zone éclairée avec la zone ombrée.

Remarquez que plus votre modèle contient de polygones et plus la frontière d’ombrage sera précise et courbe.

Pour supprimer le dégradé et obtenir une frontière d’ombrage, j’utilise la node RampColor de Blender.

Sur Blender, le shader est configuré selon l’image ci-dessous :

Blender Cel Shading
Cliquez sur l’image pour l’agrandir

Les nodes utilisées se trouvent dans les menus suivant :
Add > Input > Material. Renseignez le matériau à traiter dans la node.
Add > Input > Lamp Data. Renseignez la lampe utilisée.
Add > Vector Map > Dot Product
Add > Converter > ColorRamp
Add > Color > MixRGB
Add > Input > Texture. Utilisez la couleur de base du matériau (Input > RGB) si vous ne voulez pas vous embêter avec une texture.

De ce fait, les surfaces sont ombrées/éclairées uniformément selon si le produit scalaire est inférieur/supérieur à la valeur de la ColorRamp.

Sur Unity, j’utilise le code ci-dessous qui prend en entrée une texture ou couleur et une représentation d’ombrage (c’est le ToonLut) :

Properties {
    _Color("Color", Color) = (1,1,1,1)
    _MainTex("Texture", 2D) = "white" {}
    _ToonLut("Toon LUT", 2D) = "white" {}
}

Mon vertex shader transpose les normales de l’objet dans l’espace 3D :

struct appdata {
    float4 vertex : POSITION;
    float3 normal: NORMAL;
    float2 uv : TEXCOORD0;
};

struct v2f {
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float3 normal : TEXCOORD1;
};

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

La magie opère concrètement dans le fragment shader où je calcule le produit scalaire entre les normales et la lumière. Ce qui va me permettre de définir mon gradient cartoon (le Toon Lut). Ce Toon Lut est une image représentant l’ombrage sur l’axe u :

Toon Lut Simple
Le Toon Lut utilisé à 1 démarcation. Notez que la position de la zone de démarcation ne dépend plus du produit scalaire. Nous pouvons la définir librement.

Mais nous pourrions très bien utiliser un Toon Lut avec plusieurs niveaux colorés d’ombrage voir même de lisser les ombrages pour donner un effet Soft Shade à notre Cel Shading !

Toon Lut Complexe
Exemple de Toon Lut coloré à 2 démarcations plus où moins graduées. Avec un peu de créativité, nous pourrions ajouter des zones éclairées au sein de zones ombrées…

Il ne me reste plus qu’à multiplier cet effet à l’albedo pour colorer mon modèle. La couleur blanche du Toon Lut devient ainsi la texture originale de mon modèle et le noir vient l’assombrir tel un voile.

sampler2D _MainTex;
sampler2D _ToonLut;
fixed4 _Color;

fixed4 frag(v2f i) : SV_Target {
    float3 normal = normalize(i.normal);
    float ndotl = dot(normal, _WorldSpaceLightPos0);
    float3 lut = tex2D(_ToonLut, float2(ndotl, 0));
    float3 directDiffuse = lut * _LightColor0;
    fixed4 color = tex2D(_MainTex, i.uv) * _Color;
    color.rgb *= directDiffuse;
    color.a = 1.0;
    return color;
}

Voici le code complet du Cel Shading shader :

Pass {
    Tags {
        "LightMode" = "ForwardBase"
    }

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_fwdbase

    #include "UnityCG.cginc"
    #include "AutoLight.cginc"
    #include "Lighting.cginc"

    struct appdata {
        float4 vertex : POSITION;
        float3 normal: NORMAL;
        float2 uv : TEXCOORD0;
    };

    struct v2f {
        float4 pos : SV_POSITION;
        float2 uv : TEXCOORD0;
        float3 normal : TEXCOORD1;
    };

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

    sampler2D _MainTex;
    sampler2D _ToonLut;
    fixed4 _Color;

    fixed4 frag(v2f i) : SV_Target {
        float3 normal = normalize(i.normal);
        float ndotl = dot(normal, _WorldSpaceLightPos0);
        float3 lut = tex2D(_ToonLut, float2(ndotl, 0));
        float3 directDiffuse = lut * _LightColor0;
        fixed4 color = tex2D(_MainTex, i.uv) * _Color;
        color.rgb *= directDiffuse;
        color.a = 1.0;
        return color;
    }
    ENDCG
}

Bon, il faut aussi prendre en compte l’ombrage provoqué par l’opacité des objets qui se positionneraient entre la source de lumière et le sujet. C’est le « falloff shadow » et le shader développé actuellement nuit à ce système…

Nous pourrions également développer un effet de lumière en contre-jour, de type « rim light ».

Voici le résultat obtenu en faisant tourner la lumière :

Cel Shading ombrage doge
Toujours le plus beau chien du Monde.

À vous d’adapter le style au rendu que vous désirez. Pour la texture, plusieurs concepts se côtoient, allant du minimalisme couleur « flat » (comme c’est le cas pour mon chien) au dégradé de couleurs plus ou moins lisses.

Differents-styles-de-cel-shading
Différents styles de Cel Shading : Ni no Kuni II, Unity Chan, The Wolf Among Us.

Cel Shading 2 : Le contour

Lors de mes recherches, j’ai rencontré deux styles de contours majeurs :

  • Le contour « dessiné à la main » (hand-drawn outlines) où la pression du tracé varie en fonction de l’écart de profondeur entre deux polygones.
  • La silhouette ou surbrillance qui trace l’extérieur du modèle. Il n’y a jamais de contour à l’intérieur du modèle.
Surbrillance effect
Un contour silhouette. À gauche : Jade de Battlerite. À droite : une tour de League of Legends. Notez qu’aucun de ces deux modèles n’applique l’ombrage toon d’un Cel Shading.

Je vais m’intéresser ici au contour « dessiné à la main ».


Ajout de contours noirs au modèle sans l’ombrage précédent

Une grande majorité de jeux vidéo utilisent une technique de « duplication-inversion » du modèle.

L’idée est de légèrement grossir (fatten) une réplique que l’on va ensuite superposer sur le modèle avant de lui inverser ses normales.

inverse normal model
L’inverse légèrement grossie du modèle de chien

Attention, j’insiste sur l’utilisation d’une opération grossir (qui va uniformément gonfler les volumes de votre objet) et non sur une opération classique de mise à l’échelle qui conserve les volumes.

Fatten rendu
Une opération fatten modifie les volumes d’un modèle

Sur Blender, l’outil shrink / fatten est dans la fenêtre Mesh Tools, onglet Transformation de la vue 3D (raccourci Alt-S en Edit Mode).

Fatten
L’onglet Transformation

Sur Unity, même principe mais en ligne de code. Je vais tout d’abord créer une nouvelle Pass en inversant les normales à l’aide de la commande Cull front.

Pass {
    Cull front

    ...

    ENDCG
}

Puis je vais grossir le modèle à l’aide de l’instruction suivante dans le vertex shader :

// 0.005f est une valeur arbitraire d'épaisseur de trait.
// On pourrait aussi la variabiliser en fonction
// de la distance entre le modèle et la caméra.
o.position = position + 0.005f * mul(UNITY_MATRIX_MVP,
normalize(v.normal));

Il ne nous reste plus qu’à définir la couleur du trait à l’aide d’une variable color2 dans le fragment shader.

fixed4 frag(v2f i) : SV_Target {
    fixed4 col = _Color2;
    col.a = 1.0;
    return col;
}

Voici le code complet de l’outline shader :

Pass {
    Cull front
    Tags {
        "LightMode" = "ForwardBase"
    }

    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_fwdbase

    #include "UnityCG.cginc"
    #include "AutoLight.cginc"
    #include "Lighting.cginc"

    struct appdata {
        float4 vertex : POSITION;
        float3 normal: NORMAL;
    };

    struct v2f {
        float4 position : SV_POSITION;
    };

    v2f vert(appdata v) {
        v2f o;
        float4 position = UnityObjectToClipPos(v.vertex);
        o.position = position + 0.005f * mul(UNITY_MATRIX_MVP, normalize(v.normal));
        return o;
    }

    fixed4 _Color2;

    fixed4 frag(v2f i) : SV_Target {
        fixed4 col = _Color2;
        col.a = 1.0;
        return col;
    }
    ENDCG
}

En superposant une réplique du modèle, on est certains que le contour épousera toutes les formes de l’objet et que la pression du tracé diffère selon la profondeur, même à l’intérieur de l’objet lui-même.

Inside lines Cel Shading
Le tracé s’applique aussi à l’intérieur de l’image du modèle

Mais, en dupliquant le modèle, on double ainsi le nombre de polygones à afficher, non ? Cette technique est elle préférable d’un point de vue performance ?

En effet, on double le nombre de polygone, mais cette astuce est bien plus optimisée que celle d’appliquer un shader avec des filtres sur l’ensemble des matériaux de l’objet. On ne fait pas d’omelette sans casser des œufs.

Et puis, jouer sur les normales pour dessiner un contour, c’est quand même super classe !

L’autre solution est de réaliser un filtre de Sobel sur la profondeur avec un shader à appliquer sur l’ensemble des matériaux du modèle. Le résultat est plus difficile à configurer selon ses envies. Les performances varient aussi fortement selon les fonctions utilisées.

Avec la technique de duplication-inversion, il est facilement possible de changer la couleur et la taille du tracé. Il suffit de modifier respectivement la couleur du matériau et la grosseur du modèle inversé.

Conclusion :

Pour résumé, le Cel Shading est un shader

  • Qui cherche à obtenir un rendu 2D à partir d’un objet 3D. C’est donc une technique 2,5D.
  • Qui est relativement facile à implémenter (mais ce n’est pas un concept ultra facile non plus).
  • Qui nécessite de faibles performances matérielles.
  • Qui est agréable à regarder.
  • Qui vieillit bien dans le temps.
  • Qui facilite la lisibilité en jeu.

L’avantage des techniques abordées ici est qu’elles sont indépendantes contrairement aux techniques post-process. On peut les appliquer sur les objets que l’on veut et les configurer de manière individuelle. Rien ne m’empêche donc d’afficher à la fois un gros contour brun sur les cheveux de mon personnage et un contour beige, plus fin, sur sa peau, voir même de ne pas appliquer le Cel Shading sur une partie du corps.

Il est déconseillé d’utiliser un filtre (de type Sobel ou autre) pour tracer le contour d’un objet car le rendu est moins précis qu’avec un technique de duplication-inversion. La duplication-inversion à l’avantage d’être compatible entre Blender et Unity puisqu’il ne fait intervenir que des informations du modèle même si un shader sur Unity est préférable pour obtenir un meilleur paramétrage.

Je tiens aussi à indiquer que je ne suis clairement pas un expert du développement de shaders. Je vous invite donc fortement à me faire remarquer les éventuelles erreurs et solutions d’amélioration dans l’espace commentaire. (Notamment comment ajouter un « falloff shadow » mais aussi comment développer l’ombrage toon du Cel Shading dans Sketchfab ?)

Je compte ainsi maintenir l’article dans les mois à venir en espérant qu’il vous sera utile.

tales-of-vesperia-characters
Tales of Vesperia

Auteur : Thibaud

Je suis passionné de création de jeux d'aussi loin que je me souvienne. Diplôme d'Ingénieur en poche, j'étudie actuellement les éléments qui permettent de réaliser un bon jeu dans le but de concevoir mes prochains titres. J’espère ainsi échanger avec des passionnés et constituer une communauté francophone de Game Design.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *