Samuel Bouchet
Game developer at Lonestone game studio

Effet de distorsion plein écran avec Unity 6

16/09/2024

Analyse technique de l'astuce que j'ai utilisée dans ma dernière game jam pour un effet de distorsion d'onde (effet fullscreen) utilisant une caméra secondaire + RenderTexture.

Avec Clément Rivaille (aka Itooh), nous avons participé à la dernière game jam organisée par GMTK et avons proposé un jeu inspiré "bullet hell" où il faut alterner entre des phases de collecte et d'esquive : https://itooh.itch.io/powered-by-geometry.

À cette occasion, on en a profité pour tester Unity 6 et j'ai monté un petit effet de "Ripple", c'est-à-dire d'ondulations déformantes pour obtenir des effets grossissant ou rétrécissant de l'image.

On peut voir l'effet de déformation lorsque les projectiles approchent des lignes ou passent proche les uns des autres
On peut voir l'effet de déformation lorsque les projectiles approchent des lignes ou passent proche les uns des autres

Pour le jeu, nous avons monté deux effets : l'effet de réduction ci-dessus, mais aussi un effet de vague d'explosion.

Les tourelles explosent en lançant leur projectile, provoquant une vague d'explosion
Les tourelles explosent en lançant leur projectile, provoquant une vague d'explosion

Le principe :

  • On ajoute un effet de post effect full screen à la render pipeline.
  • L'effet de post effect s'appuie sur une deuxième image qui contient les informations de déformation (l'équivalent d'une normalmap mais qui indique un décalage pour chaque pixel plutôt qu'une inclinaison). Ce shader fera un sampling de l'image d'origine en appliquant le décalage indiqué.
  • La deuxième image avec informations de déformation est générée via une deuxième caméra sur un layer dédié qui rend dans une RenderTexture.

Le Post Effect

Dans Unity 6 avec URP, l'effet Post Effect en plein écran peut s'avérer a priori plus complexe que précédemment. En rendu built-in, on ferait typiquement un script qui surcharge OnRenderImage et qui utilise Graphics.Blit à partir des textures source et de destination fournies par l'API. Malheureusement cette méthode n'est plus disponible en URP, et la nouvelle fonctionnalité pour faire cette opération de post effet s'appelle une Render Feature.

À ce moment c'est la panique dans la jam : quoi ? Implémenter une ScriptableRendererFeature qui va lui-même nécessiter d'implémenter une ScriptableRenderPass ?

Mais heureusement, on peut trouver directement dans l'interface un petit raccourci, voilà comment ça fonctionne :

Configurer le render pipeline

Avec URP, la pipeline de rendu est configurée via deux objets de configuration qui sont par défaut dans le dossier Assets/Settings :

  • Un Universal Render Pipeline Asset
  • Un Renderer2DData (pour un projet 2D mais il y a l'équivalent en 3D aussi)

Dans ce deuxième fichier, on ajoute un FullScreenPassRendererFeature en appuyant sur le bouton dédié "Add Renderer Feature" en bas de l'inspecteur.

Project View: select the Assets/Settings/Renderer2D asset. Inspector view: use 'Add Render Feature' button then 'Full Screen Pass Renderer Feature'

Project View: select the Assets/Settings/Renderer2D asset. Inspector view: use 'Add Render Feature' button then 'Full Screen Pass Renderer Feature'

On utilise la configuration suivante :

  • Name : comme vous voulez (ici j'ai gardé la valeur par défaut)
  • InjectionPoint : par défaut "After Rendering Post Processing", mais pour notre jeu ça ne fonctionnait pas car on rajoute un bloom en post effect, et le bloom déformé rend très mal ! Donc pour cet effet je recommande une injection "Before rendering Post Processing" pour que les autres post effects soient appliqués sur l'image déformée.
  • Fetch Color Buffer : coché ! Ça rajoute une passe de copie mais on a besoin des couleurs initiales pour faire notre effet de déformation.
  • Bind Depth-Stencil : décoché. Le depth stencil contrôle la profondeur et le masquage des pixels lors du rendu 3D. Pour notre rendu 2D, on ne l'utilisera pas ici.
  • Pass Material : l'instance du material du Shader de Post Effect.

Voyons voir comment créer ce shader et son material !

Le Shader de post effect

Initialisation du shader

Pour créer le shader, dans la vue projet : clic droit > Create > Shader Graph > URP > Fullscreen Shader Graph. Ça permettra de créer un shader préconfiguré pour un effet de post effect full screen.

Project View, right click: Create > Shader Graph > URP > Fullscreen Shader Graph
Project View, right click: Create > Shader Graph > URP > Fullscreen Shader Graph

Une fois dans le shader graph, on a le point de sortie (Le fragment Base Color) mais le point d'entrée n'est pas évident. On le trouve dans la doc officielle : https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0/manual/post-processing/post-processing-custom-effect-low-code.html

Comme indiqué, on crée un nœud URP Sample Buffer configuré avec la source BlitSource.

Shader graph example: `URP Sample Buffer` node outputs to Fragment base color

Cette configuration nous donne un Post Effect qui ne fait que copier les pixels d'origine. Sur le nœud URP Sample Buffer la source d'information du pixel est obtenue à partir d'une valeur UV qui par défaut est un UV screenspace ((0,0) en bas à gauche de l'écran et (1,1) en haut à droite).

Effet de décalage

Modifier cet UV nous permettra de choisir pour chaque pixel d'aller piocher le pixel source en décalé pour créer l'effet voulu. Pour les projectiles, une texture "rapetissant" va concentrer la lecture des pixels vers le milieu et au contraire pour l'explosion on repoussera la lecture vers l'extérieur.

On partira des conventions suivantes pour la texture d'information :

  • Les canaux R et G contiendront les informations d'offset sur les axes X et Y respectivement.
  • Comme les couleurs négatives n'existent pas, on normalisera comme pour un normal map : 0.5 est la valeur neutre, en dessous on décale dans le négatif, au-dessus on décale dans le positif.

Les images ci-dessous représentent les textures de déformation du projectile et de l'effet d'explosion. Pour le projectile par exemple, si on se concentre uniquement sur la valeur rouge, on voit qu'en partant du bord gauche la couleur est neutre (rouge 0.5) puis transitionne de plus en plus fort vers le noir lorsqu'on approche du centre (où le vert qui reste seul puisqu'il n'y a plus de rouge), et de l'autre côté du cercle l'inverse se produit on passe rapidement d'un rouge intense proche du centre (1) à un rouge neutre (0.5) en allant vers le bord droit.

L'intensité de noir/rouge au niveau du centre indique que l'effet sera le plus fort au près du projectile, et donc plus léger près du bord. Plus l'intensité est forte (noire ou rouge) et plus on ira chercher loin le pixel à lire dans la texture source vers la gauche ou vers la droite. Cela va ici créer un effet rétrécissant autour des particules en allant chercher les pixels plus loin autour.

Pour l'effet grossissant au contraire, on ira chercher à afficher des pixels plus près du centre ce qui repoussera et étirera les pixels de la texture source du centre vers l'extérieur.

Exemple de texture pour un effet rapetissant autour d'un projectile. Le point noir correspond à la forme du projectile et le contour à la zone d'effet

Exemple de texture pour un effet rapetissant autour d'un projectile. Le point noir correspond à la forme du projectile et le contour à la zone d'effet

Exemple de texture pour un effet agrandissant lors d'une explosion
Exemple de texture pour un effet agrandissant lors d'une explosion

Programmation du shader

On monte le shader ainsi :

  • Un paramètre _Secondary de type texture qui sera notre source d'information. À terme cette texture contiendra uniquement des infos rouge et vertes comme montré ci-dessus.
  • Un nœud SampleTexture2D pour lire les informations de cette texture
  • On soustrait 0.5 à chaque canal source pour obtenir une valeur entre -0.5 et 0.5
  • On ajoute un nœud UV (channel UV0) comme source d'UV
  • On additionne le résultat de la soustraction à l'UV pour obtenir l'UV décalé
  • On utilise l'UV décalé (résultat du Add) comme source d'UV de l'URP sample buffer
  • On pourra introduire un paramètre float DistortionStrength pour configurer l'amplitude du décalage, on le multiplie avec le résultat de la soustraction avant de l'additionner à l'UV

Ci-dessous le graph complet qui comporte une opération supplémentaire : si l'image source est très proche du noir (Add(R+G) → Step(seuil de 0.1)) on considère qu'on n'a pas d'information de décalage et on annule l'offset (multiplication par 0 qui annule tout offset).

Illustration of the full shader graph
Graph complet du shader

Configuration du material

On peut maintenant configurer le material du shader dans le Renderer2DData :

  • Pour créer le material, dans la vue Project, clic droit sur le shader > Create > Material. Cela crée un matériau attaché au shader automatiquement.
  • On attache le material dans l'inspecteur du Renderer2DData
  • On configure le material
Animation d'un test de shader, utilisant une texture orange. L'image se décale entièrement.
Animation d'un test de shader, utilisant une texture orange. L'image se décale entièrement.

Ici je teste avec une texture orange (rouge=1, vert=0.5), toute l'image est décalée vers la gauche lorsqu'on augmente la distorsion strength.

  • La valeur du canal rouge est proche de 1 ce qui décale la lecture plus à droite. La coordonnée UV est décalée plus à droite, le pixel échantillonné par le nœud Sample Buffer est donc plus à droite dans la source ce qui décale l'image rendue vers la gauche et laisse une bande noire sur la droite (il n'y a plus de pixel à lire lorsqu'on sort de la zone de rendu de la texture source ! → rendu noir).
  • La valeur du canal vert est proche de 0.5, pas de changement vertical puisqu'on n'applique aucun décalage à l'UV d'origine

Il reste à générer cette texture de déformation pour obtenir quelque chose de plus intéressant !

Générer la texture de déformation

Voici les étapes du montage :

  • Créer une asset RenderTexture (ici nommée "Camera Output") qui respecte dans ses dimensions le ratio cible en R8G8_UNORM
Inspector view of the `Camera Output` Render Texture.
Inspector view of the Camera Output Render Texture.
  • Créer un layer dédié (ici nommé "Secondary")
  • Créer une caméra secondaire qui filme uniquement secondary et ignore le reste (culling mask)
  • Il faudra pour cette caméra créer un Renderer2DData (nommé par ex. Renderer2DSecondary) inspiré du premier Renderer2DData qu'on avait précédemment mais qui désactive tous les posts effects
  • Ajouter ce renderer2DData à la renderer list du Universal Render Pipeline Asset
  • Utiliser ce renderer au niveau de la caméra secondaire
  • Configurer l'output pour utiliser la RenderTexture créée précédemment
  • Configurer la caméra principale pour ignorer secondary (culling mask)
  • Ajouter un sprite de déformation aux projectiles sur le layer "Secondary"
  • Projectile est un gameobject vide qui sert de parent aux éléments
  • SpriteRenderer est un Sprite Renderer, le rendu normal du sprite sur le layer Default
  • ProjectileMask est un Sprite Renderer du même sprite mais complètement noir sur le layer Secondary. En s'affichant par-dessus Shrink, il permet d'éviter que l'effet de déformation ne soit appliqué au projectile lui-même (sinon il apparaîtrait tout rapetissé).
  • Shrink est un Sprite Renderer de l'effet qui est rendu sur le layer Secondary. C'est l'effet de déformation. Il est environ 3 fois plus large que le projectile lui-même pour que la zone d'effet soit autour du projectile.

Dans le cadre de notre jeu Powered By Geometry, j'ai également programmé un shader additionnel pour générer la texture d'UV déformé qu'on peut observer ci-dessus mais il est également possible d'utiliser un sprite pré-calculé.

Pour l'effet d'explosion, j'utilise un émetteur de particules configuré sur le layer Secondary et qui anime l'agrandissement puis le fadeout d'une unique particule de grande taille, similaire à celle des projectiles sinon que les couleurs sont inversées, pour donner un effet qui repousse plutôt qu'un effet convergeant.

Finalisation

Tout est prêt ! La dernière étape consiste à renseigner cette RenderTexture (Camera Output) comme valeur _Secondary du material créé précédemment.

Lorsqu'il y a des projectiles à l'écran ça peut ressembler à ça :

Exemple de rendu secondaire avec quelques gros projectiles.
Exemple de rendu secondaire avec quelques gros projectiles.
Exemple de rendu secondaire avec beaucoup de projectiles de toutes tailles.
Exemple de rendu secondaire avec beaucoup de projectiles de toutes tailles.

Et pour un aperçu du rendu final lorsqu'il y a plein de particules partout à l'écran, pourquoi ne pas tester le jeu ? Il est disponible gratuitement et devrait tourner de manière performante dans votre navigateur grâce à l'export WebGPU :

https://itooh.itch.io/powered-by-geometry

Vous lisez peut-être cet article depuis un mobile, voici donc un petit extrait de l'effet ondulatoire qu'on devine malgré les artefacts de compression, et qui donne un petit effet aquatique à la scène !

Merci d'avoir lu jusqu'au bout ! Si ce contenu vous a plu, pensez à me suivre sur vos réseaux sociaux préférés :

Published by Samuel Bouchet.
Do you like reading SF? Try out latest game Neoproxima