Créer un Effet de Contour avec Unity HDRP

En général, créer un effet de contour est relativement aisé mais les choses se gâtent quand on veut utiliser le système de rendu HDRP d’Unity principalement en raison du manque de guides et de ressources.

Je vous propose ici de regarder ensemble comment on peut faire ceci.


Le Résultat Final

Ce qu’on veut c’est un rendu qui:

  1. Affiche le contour d’un objet.
  2. Que le contour soit visible (ou non) si une partie de l’objet est cachée.
  3. Pouvoir changer la couleur interne (typiquement pour avoir une légère transparence).
  4. Pouvoir être rendu après le post-processing ou bien avant si on veut que le contour soit sujet à l’anticrénelage.
  5. Pouvoir, évidemment, configurer la couleur.
  6. Et enfin, en changer l’épaisseur.

 

Comment va-t-on faire?

Une excellente question qui m’a occupé pendant une bonne journée. On va procéder en plusieurs étapes:

  1. Nous allons ajouter une couche (« Layer ») et déterminer quels objets devront avoir un contour. Cela permettra par la suite de créer un système qui affiche le contour d’un objet quand la souris le survole par exemple.
  2. On créera ensuite deux « custom pass » qui appliqueront l’effet de contour sur les objets dans la couche qu’on aura définie préalablement.
  3. Enfin on créera deux « shaders » et les matériaux associés qu’on assignera aux « custom pass ».
  4. Enfin on regardera comment fonctionne l’ensemble et régler un petit souci qui peut se produire quand on utilise le TAA comme méthode d’anticrénelage.

 

1. Création d’une Couche

Probablement la partie la plus simple. Quand vous inspectez un objet sur Unity, cliquez sur la couche (« Layer ») puis cliquez sur « Add Layer ». Ajoutez ensuite un couche dans la liste que l’on appellera « Selection ».

 

2. Création des Custom Pass

Ajoutez un GameObject vide dans votre projet. Nommez-le « Custom Passes » et ajoutez le composant « Custom Pass Volume » dedans. Utilisez le petit symbole + en bas pour ajouter:

  1. Draw Renderers Custom Pass
  2. Fullscreen Custom Pass

Et configurez-les comme suit (ignorez les matériaux, on va s’en occuper):

3. Création des Shaders et des Matériaux

Comme dit précédemment, nous allons créer deux shaders. Le premier s’occupera de transmettre au deuxième les informations dont on a besoin.

Dans votre projet, créez les deux shaders (Create -> Shader -> HDRP Custom Renderers Pass et HDRP Custom Fullscreen Pass). Appellez-les respectivement « OutlineRendererShader » et « OutlineFullscreenShader » (ou comme vous voulez si vous vous y retrouvez).

Voici le code des deux shaders:

OutlineRendererShader.shader

Shader "OutlineShader/Objects"
{
    Properties
    {
	_ContourColor("Contour Color", Color) = (1,1,1,1)
        _MaxDistance("Max Distance", float) = 15
    }

    HLSLINCLUDE

    #pragma target 4.5
    #pragma only_renderers d3d11 ps4 xboxone vulkan metal switch

    // #pragma enable_d3d11_debug_symbols

    //enable GPU instancing support
    #pragma multi_compile_instancing
    #pragma instancing_options renderinglayer

    ENDHLSL

    SubShader
    {
        Pass
        {
            Name "FirstPass"
            Tags { "LightMode" = "FirstPass" }

            Blend Off
            ZWrite On
            ZTest LEqual

            Cull Back

            HLSLPROGRAM

            // List all the attributes needed in your shader (will be passed to the vertex shader)
            // you can see the complete list of these attributes in VaryingMesh.hlsl
            #define ATTRIBUTES_NEED_TEXCOORD0
            #define ATTRIBUTES_NEED_NORMAL
            #define ATTRIBUTES_NEED_TANGENT

            // List all the varyings needed in your fragment shader
            #define VARYINGS_NEED_TEXCOORD0
            #define VARYINGS_NEED_TANGENT_TO_WORLD
            #define VARYINGS_NEED_POSITION_WS
            
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/RenderPipeline/RenderPass/CustomPass/CustomPassRenderers.hlsl"
            
            float _MaxDistance;
			float4 _ContourColor;
            float _AlphaThreshold;

            // Put the code to render the objects in your custom pass in this function
            void GetSurfaceAndBuiltinData(FragInputs fragInputs, float3 viewDirection, inout PositionInputs posInput, out SurfaceData surfaceData, out BuiltinData builtinData)
            {
                if( length(fragInputs.positionRWS) > _MaxDistance ) discard;
               
                // Write back the data to the output structures
                ZERO_INITIALIZE(BuiltinData, builtinData); // No call to InitBuiltinData as we don't have any lighting
                ZERO_INITIALIZE(SurfaceData, surfaceData); // No call to InitBuiltinData as we don't have any lighting
                builtinData.opacity = 1;
                builtinData.emissiveColor = float3(0, 0, 0);
                surfaceData.color = float3(1,1,1) * _ContourColor.rgb;
            }

            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/RenderPipeline/ShaderPass/ShaderPassForwardUnlit.hlsl"

            #pragma vertex Vert
            #pragma fragment Frag

            ENDHLSL
        }
    }
}

Dans ce premier shader on se content de transmettre au second les points dont on veut le contour au deuxième. Il y a deux propriétés qui permettront de contrôler:

  1. La couleur du contour.
  2. La distance maximale à laquelle s’affichera le contour.

Vous pouvez évidemment retirer la deuxième propriété si vous ne désirez pas de cette limite.

OutlineFullscreenShader.shader

Shader "OutlineShader/Fullscreen"
{
    properties
    {
        _Iterations ("Iterations", Range(1,3) ) = 1
        _OutlineWidth ("Outline Width", Float ) = 5
        _InsideColor ("Inside Color", Color) = (1, 1, 0, 0.5)
    }

    HLSLINCLUDE

    #pragma vertex Vert

    #pragma target 4.5
    #pragma only_renderers d3d11 ps4 xboxone vulkan metal switch

    #include "Packages/com.unity.render-pipelines.high-definition/Runtime/RenderPipeline/RenderPass/CustomPass/CustomPassCommon.hlsl"

    // The PositionInputs struct allow you to retrieve a lot of useful information for your fullScreenShader:
    // struct PositionInputs
    // {
    //     float3 positionWS;  // World space position (could be camera-relative)
    //     float2 positionNDC; // Normalized screen coordinates within the viewport    : [0, 1) (with the half-pixel offset)
    //     uint2  positionSS;  // Screen space pixel coordinates                       : [0, NumPixels)
    //     uint2  tileCoord;   // Screen tile coordinates                              : [0, NumTiles)
    //     float  deviceDepth; // Depth from the depth buffer                          : [0, 1] (typically reversed)
    //     float  linearDepth; // View space Z coordinate                              : [Near, Far]
    // };

    // To sample custom buffers, you have access to these functions:
    // But be careful, on most platforms you can't sample to the bound color buffer. It means that you
    // can't use the SampleCustomColor when the pass color buffer is set to custom (and same for camera the buffer).
    // float3 SampleCustomColor(float2 uv);
    // float3 LoadCustomColor(uint2 pixelCoords);
    // float LoadCustomDepth(uint2 pixelCoords);
    // float SampleCustomDepth(float2 uv);

    // There are also a lot of utility function you can use inside Common.hlsl and Color.hlsl,
    // you can check them out in the source code of the core SRP package.
    
    #define v2 1.41421
    #define c45 0.707107
    #define c225 0.9238795
    #define s225 0.3826834
    
    #define MAXSAMPLES 16
    static float2 offsets[MAXSAMPLES] = {
        float2( 1, 0 ),
        float2( -1, 0 ),
        float2( 0, 1 ),
        float2( 0, -1 ),
        
        float2( c45, c45 ),
        float2( c45, -c45 ),
        float2( -c45, c45 ),
        float2( -c45, -c45 ),
        
        float2( c225, s225 ),
        float2( c225, -s225 ),
        float2( -c225, s225 ),
        float2( -c225, -s225 ),
        float2( s225, c225 ),
        float2( s225, -c225 ),
        float2( -s225, c225 ),
        float2( -s225, -c225 )
    };
    
    int _Iterations;
    float _OutlineWidth;
    float4 _InsideColor;

    float4 FullScreenPass(Varyings varyings) : SV_Target
    {
        UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(varyings);

        float depth = LoadCameraDepth(varyings.positionCS.xy);
        PositionInputs posInput = GetPositionInput(varyings.positionCS.xy, _ScreenSize.zw, depth, UNITY_MATRIX_I_VP, UNITY_MATRIX_V);
        float3 viewDirection = GetWorldSpaceNormalizeViewDir(posInput.positionWS);
		float4 c = LoadCustomColor(posInput.positionSS);
        float2 uvOffsetPerPixel = 1.0/_ScreenSize .xy;
        uint sampleCount = min( 2 * pow(2, _Iterations ), MAXSAMPLES ) ;
        float4 outline = float4(0,0,0,0);
        for (uint i=0 ; i<sampleCount ; ++i )
        {
            outline =  max( SampleCustomColor( posInput.positionNDC + uvOffsetPerPixel * _OutlineWidth * offsets[i] ), outline );
        }

        float4 o = float4(0,0,0,0);
        o = lerp(o, float4(outline.rgb, 1), outline.a);
        o = lerp(o, _InsideColor, c.a);
        return o;
    }

    ENDHLSL

    SubShader
    {
        Pass
        {
            Name "Custom Pass 0"

            ZWrite Off
            ZTest Always
            Blend SrcAlpha OneMinusSrcAlpha
            Cull Off

            HLSLPROGRAM
                #pragma fragment FullScreenPass
            ENDHLSL
        }
    }
    Fallback Off
}

Il se passe déjà un peu plus de choses. On a d’abord défini les propriétés qui sont le nombre d’itérations (on verra en jouant avec l’impact sur la qualité du résultat), l’épaisseur du contour et la couleur intérieure (qui peut inclure de la transparence et donc être complètement invisible).

Pour le reste, on se sert du tampon de profondeur pour créer une silhouette plus grosse que l’objet (plus grosse de l’épaisseur du contour) avec la couleur du contour et puis on change la couleur des pixels intérieurs avec la couleur interne.

Il ne nous reste plus qu’à créer deux matériaux avec chacun de ces shaders dans notre projet. (Create -> Material) et on sélectionne le bon shader.

Enfin on assigne les nouveaux matériaux créés sur les « custom pass ». Mettez bien le « shader fullscreen » sur le « custom pass fullscreen », etc …

Vous pouvez jouer avec le « depth test » du « renderer custom pass » et le mettre sur « less equal » par exemple. Vous verrez que les parties cachées de l’objet ne contribuent plus au contour.

 

4. Un peu de fignolage

Si vous avez les malheur de vous servir du TAA (Temporal Anti-Aliasing) vous observerez quelques petits soucis quand la caméra bouge (le contour suit, mais pas immédiatement).

Ceci est du au fait que nos « custom pass » sont injectée « before post-processing » c’est à dire avant que le TAA ne soit appliqué et du coup, le contour aussi est affecté par le TAA.

Si vous changez le point d’injection en « After Post-Process » votre contour sera pixelisé (ce qui peut être désirable) mais surtout, si vous utilisez le TAA, vous aurez des soucis si vous essayez d’utiliser le « depth test » puisque le TAA modifie également le tampon de profondeur !

Le mieux à faire dans ce cas est de faire deux « custom pass volume », un avant le « post-processing » et un après comme ceci:

Voilà pour le moment, il est toujours possible de modifier un peu les choses pour les améliorer mais c’est déjà un bon début.