Algorithme de rendu de volume rapide et facile


J'ai récemment écrit un petit ShaderToy qui fait un rendu volumétrique simple, puis j'ai décidé de publier un article expliquant son travail. Le ShaderToy interactif lui-même peut être consulté ici . Si vous lisez à partir d'un téléphone ou d'un ordinateur portable, je vous recommande de regarder cette version rapide. J'ai inclus des extraits de code dans le message qui vous aideront à comprendre les performances de ShaderToy à un niveau élevé, mais ils n'ont pas tous les détails. Si vous voulez creuser plus profondément, je vous recommande de vérifier avec le code ShaderToy.

Mon ShaderToy avait trois tâches principales:

  1. Exécution en temps réel
  2. Simplicité
  3. Exactitude physique (... ou quelque chose comme ça)

Je vais commencer par cette scène de code vierge. Je n'entrerai pas dans les détails de l'implémentation, car ce n'est pas très intéressant, mais je vais vous dire brièvement par où commencer:

  1. Ray tracing d'objets opaques. Tous les objets sont des primitives avec de simples intersections avec des rayons (1 plan et 3 sphères)
  2. Pour calculer l'éclairage, l'ombrage Phong est utilisé et, dans trois sources lumineuses sphériques, un coefficient d'atténuation de la lumière personnalisé est utilisé. Les rayons d'ombres ne sont pas nécessaires, car nous n'illuminons que l'avion.

Voici à quoi ça ressemble:

Capture d'écran de ShaderToy

Nous rendrons le volume comme un passage séparé qui se mélange à une scène opaque; ceci est similaire à la façon dont tous les moteurs de rendu en temps réel traitent individuellement les surfaces opaques et translucides.

Partie 1: simuler le volume


Mais d'abord, avant de pouvoir commencer le rendu volumétrique, nous avons besoin de ce même volume! Pour simuler le volume, j'ai décidé d'utiliser des fonctions de distance signée (SDF). Pourquoi précisément les fonctions des champs de distance? Parce que je ne suis pas artiste, mais ils vous permettent de créer des formes très organiques en quelques lignes de code. Je ne parlerai pas en détail des fonctions des distances avec un signe, car Inigo Kiles les a déjà merveilleusement expliquées. Si vous êtes curieux, alors il y a une grande liste de différentes fonctions de distances de signe et de modificateurs. Et voici un autre article sur ces SDF raymarching.

Commençons par une simple et ajoutons une sphère ici:

Capture d'écran de ShaderToy

Nous allons maintenant ajouter une autre sphère et utiliser la conjugaison lisse pour fusionner les fonctions de distance des sphères. Ce code que j'ai pris directement de la page Inigo, mais pour plus de clarté, je vais l'insérer ici:

// Taken from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSmoothUnion( float d1, float d2, float k ) 
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

L'appariement en douceur est un outil extrêmement puissant, car vous pouvez obtenir quelque chose de très intéressant en le combinant simplement avec quelques formes simples. Voici à quoi ressemblent mes nombreuses sphères conjuguées en douceur:

Capture d'écran de ShaderToy

Nous avons donc obtenu quelque chose en forme de larme, mais nous avons besoin de quelque chose de plus comme un nuage qu'une goutte. Une grande caractéristique du SDF est la facilité avec laquelle il est possible de déformer la surface en ajoutant simplement un peu de bruit au SDF. Ajoutons donc un mouvement brownien fractal (fBM) au-dessus du bruit, en utilisant la position pour indexer la fonction de bruit. Inigo Kiles a également couvert ce sujet dans un excellent article sur le bruit fBM. Voici à quoi ressemblera l'image avec du bruit fBM superposé:

Capture d'écran de ShaderToy

Bien! Grâce au bruit fBM, l'objet a soudainement commencé à paraître beaucoup plus intéressant!

Maintenant, nous devons créer l'illusion que le volume interagit avec le plan de la terre. Pour ce faire, j'ai ajouté une distance du plan signé légèrement en dessous du plan du sol et réutilisé la combinaison d'appariement en douceur avec une valeur d'appariement très agressive (paramètre k). Après cela, nous avons obtenu cette image:

Capture d'écran de ShaderToy

La touche finale sera le changement de l'indice xz du bruit fBM au fil du temps, de sorte que le volume ressemble à un brouillard tourbillonnant. En mouvement, ça a l'air très bien!

Capture d'écran de ShaderToy

Super, nous avons quelque chose comme un nuage! Le code de calcul SDF est également assez compact:

float QueryVolumetricDistanceField( in vec3 pos)
{    
    vec3 fbmCoord = (pos + 2.0 * vec3(iTime, 0.0, iTime)) / 1.5f;
    float sdfValue = sdSphere(pos, vec3(-8.0, 2.0 + 20.0 * sin(iTime), -1), 5.6);
    sdfValue = sdSmoothUnion(sdfValue,sdSphere(pos, vec3(8.0, 8.0 + 12.0 * cos(iTime), 3), 5.6), 3.0f);
    sdfValue = sdSmoothUnion(sdfValue, sdSphere(pos, vec3(5.0 * sin(iTime), 3.0, 0), 8.0), 3.0) + 7.0 * fbm_4(fbmCoord / 3.2);
    sdfValue = sdSmoothUnion(sdfValue, sdPlane(pos + vec3(0, 0.4, 0)), 22.0);
    return sdfValue;
}

Il s'agit simplement de rendre un objet opaque. Nous avons besoin d'un magnifique brouillard magnifique!

Comment le restituer sous forme de volume et non d'objet opaque? Parlons d'abord de la physique que nous simulons. Le volume est un grand nombre de particules dans une certaine zone de l'espace. Et quand je dis «énorme», je veux dire «ÉNORME». À tel point que la modélisation de chacune de ces particules est aujourd'hui une tâche impossible, même pour un rendu hors ligne. Le feu, le brouillard et les nuages ​​en sont de bons exemples. À strictement parler, tout est volume, mais pour des raisons de rapidité des calculs, il est plus facile de fermer les yeux sur cela et de prétendre que ce n'est pas le cas. Nous représentons l'accumulation de ces particules comme des valeurs de densité qui sont généralement stockées dans une sorte de grille 3D (ou quelque chose de plus complexe, par exemple, dans OpenVDB).

Lorsque la lumière traverse un volume, une paire de phénomènes peut se produire lorsque la lumière entre en collision avec une particule. Il peut soit se disperser et aller dans l'autre sens, soit une partie de la lumière peut être absorbée par la particule et se dissoudre. Pour respecter l'exigence d'exécution en temps réel, nous effectuerons ce qu'on appelle la diffusion unique. Cela signifie ce qui suit: nous supposerons que la lumière n'est diffusée qu'une seule fois, lorsque la lumière entre en collision avec une particule et vole vers la caméra. Autrement dit, nous ne serons pas en mesure de simuler les effets de la diffusion multiple, par exemple le brouillard, dans lequel les objets à distance semblent généralement plus vagues. Mais pour notre système, cela suffit. Voici à quoi ressemble la diffusion unique lors du raymarching:

Capture d'écran de ShaderToy

Le pseudocode pour cela ressemble à ceci:

for n steps along the camera ray:
   Calculate what % of your ray hit particles (i.e. were absorbed) and needs lighting
   for m lights:
      for k steps towards the light:
         Calculate % of light that were absorbe in this step
      Calculate lighting based on how much light is visible
Blend results on top of opaque objects pass based on % of your ray that made it through the volume

Autrement dit, nous avons affaire à des calculs de complexité O (n * m * k). Le GPU devra donc travailler dur.

Nous calculons l'absorption


Tout d'abord, examinons l'absorption de la lumière en volume le long du faisceau de la caméra (c'est-à-dire, n'effectuons pas encore de raymarking dans la direction des sources de lumière). Pour ce faire, nous avons besoin de deux actions:

  1. Effectuer un raymarching à l'intérieur du volume
  2. Calculer l'absorption / l'éclairage à chaque étape

Pour calculer la quantité de lumière absorbée en chaque point, nous utilisons la loi de Bouguer - Lambert - Beer , qui décrit l'atténuation de la lumière lors du passage à travers un matériau. Les calculs sont étonnamment simples:

float BeerLambert(float absorptionCoefficient, float distanceTraveled)
{
    return exp(-absorptionCoefficient * distanceTraveled);
}

Le coefficient d'absorption est un paramètre matériel. Par exemple, dans un volume transparent, par exemple dans l'eau, cette valeur sera faible et pour quelque chose de plus épais, par exemple du lait, le coefficient sera plus élevé.

Pour effectuer un raymarching de volume, nous prenons simplement des étapes d'une taille fixe le long du faisceau et obtenons une absorption à chaque étape. Vous ne comprenez peut-être pas pourquoi prendre des mesures fixes au lieu de quelque chose de plus rapide, par exemple, tracer une sphère, mais si vous vous souvenez que la densité dans le volume est hétérogène, alors tout devient clair. Vous trouverez ci-dessous le code de raymarching et d'absorption d'accumulation. Certaines variables sont en dehors de la portée de cet extrait de code, alors consultez l'implémentation complète dans ShaderToy.

float opaqueVisiblity = 1.0f;
const float marchSize = 0.6f;
for(int i = 0; i < MAX_VOLUME_MARCH_STEPS; i++) {
	volumeDepth += marchSize;
	if(volumeDepth > opaqueDepth) break;
	
	vec3 position = rayOrigin + volumeDepth*rayDirection;
	bool isInVolume = QueryVolumetricDistanceField(position) < 0.0f;
	if(isInVolume) 	{
		float previousOpaqueVisiblity = opaqueVisiblity;
		opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
		float absorptionFromMarch = previousOpaqueVisiblity - opaqueVisiblity;
		for(int lightIndex = 0; lightIndex < NUM_LIGHTS; lightIndex++) {
			float lightDistance = length((GetLight(lightIndex).Position - position));
			vec3 lightColor = GetLight(lightIndex).LightColor * GetLightAttenuation(lightDistance);  
			volumetricColor += absorptionFromMarch * volumeAlbedo * lightColor;
		}
		volumetricColor += absorptionFromMarch * volumeAlbedo * GetAmbientLight();
	}
}

Et voici ce que nous obtenons avec ceci:

Capture d'écran de ShaderToy

On dirait de la barbe à papa! Peut-être que pour certains effets cela suffira! Mais nous manquons d'occultation. La lumière atteint également toutes les parties du volume. Mais ce n'est pas physiquement correct, selon la taille du volume entre le point rendu et la source lumineuse, nous recevrons une quantité différente de lumière entrante.

Auto-ombrage


Nous avons déjà fait le plus difficile. Nous devons faire la même chose que nous avons fait pour calculer l'absorption le long du faisceau de la caméra, mais uniquement le long du faisceau de lumière. Le code pour calculer la quantité de lumière atteignant chaque point sera essentiellement une répétition du code, mais sa duplication est plus facile que de pirater HLSL pour obtenir la récursion dont nous avons besoin. Voici donc à quoi cela ressemblera:

float GetLightVisiblity(in vec3 rayOrigin, in vec3 rayDirection, in float maxT, in int maxSteps, in float marchSize) {
    float t = 0.0f;
    float lightVisiblity = 1.0f;
    for(int i = 0; i < maxSteps; i++) {                       
        t += marchSize;
        if(t > maxT) break;

        vec3 position = rayOrigin + t*rayDirection;
        if(QueryVolumetricDistanceField(position) < 0.0) {
            lightVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
        }
    }
    return lightVisiblity;
}

L'ajout de l'observation automatique nous donne les avantages suivants:

Capture d'écran de ShaderToy

Adoucir les bords


Pour le moment, j'aime déjà assez bien notre volume. Je l'ai montré au chef talentueux du département VFX de The Coalition, James Sharp. Il a immédiatement remarqué que les bords du volume étaient trop nets. Et cela est absolument vrai - des objets comme les nuages ​​sont constamment dispersés dans l'espace qui les entoure, de sorte que leurs bords se mélangent avec l'espace vide autour du volume, ce qui devrait conduire à la création de bords très lisses. James m'a proposé une excellente idée - réduire la densité en fonction de la proximité du bord. Et puisque nous travaillons avec des fonctions de distance avec un signe, c'est très simple à mettre en œuvre! Ajoutons donc une fonction qui peut être utilisée pour demander la densité à n'importe quel point du volume:

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
    return sdfMultiplier;
}

Et puis nous l'effondrons simplement dans la valeur d'absorption:

opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT * GetFogDensity(position), marchSize);

Et voici à quoi ça ressemble:

Capture d'écran de ShaderToy

Fonction densité


Maintenant que nous avons la fonction de densité, vous pouvez facilement ajouter un peu de bruit au volume pour lui donner des détails supplémentaires et de la splendeur. Dans ce cas, je réutilise simplement la fonction fBM que nous avons utilisée pour régler la forme du volume.

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
   return sdfMultiplier * abs(fbm_4(position / 6.0) + 0.5);
}

Et nous avons donc obtenu ce qui suit:

Capture d'écran de ShaderToy

Auto-ombrage opaque


Le volume est déjà assez joli! Mais un peu de lumière y coule encore. Ici, nous voyons comment la couleur verte s'infiltre là où le volume devrait l'absorber:

Capture d'écran de ShaderToy

Cela se produit car les objets opaques sont rendus avant le rendu du volume, ils ne prennent donc pas en compte l'ombrage provoqué par le volume. C'est assez facile à résoudre - nous avons une fonction GetLightVisiblity que nous pouvons utiliser pour calculer l'ombrage, nous avons donc juste besoin de l'appeler pour éclairer un objet opaque. Nous obtenons ce qui suit:

Capture d'écran de ShaderToy

En plus de créer de belles ombres multicolores, cela aide à améliorer les ombres et à augmenter le volume dans la scène. De plus, grâce aux bords lisses du volume, nous obtenons des ombres douces, malgré le fait que, à proprement parler, nous travaillons avec des sources ponctuelles d'éclairage. C'est tout! Beaucoup plus peut être fait ici, mais il me semble que j'ai atteint la qualité visuelle dont j'ai besoin, tout en conservant la relative simplicité de l'exemple.

Optimisations


À la fin, je vais énumérer brièvement quelques optimisations possibles:

  1. Avant d'effectuer le raymarching en direction de la source lumineuse, il est nécessaire de vérifier par la valeur de la décoloration de la lumière si une quantité importante de cette lumière atteint vraiment le point en question. Dans mon implémentation, je regarde la luminosité de la lumière multipliée par l'albédo du matériau et je m'assure que la valeur est suffisamment grande pour que le raymarching soit effectué.
  2. , , raymarching
  3. raymarching . , . , raymarching , .


C'est tout! Personnellement, j'ai été surpris que vous puissiez créer quelque chose d'assez physiquement correct dans une si petite quantité de code (environ 500 lignes). Merci d'avoir lu, j'espère que c'était intéressant.

Et encore une remarque: voici un changement amusant - j'ai ajouté une émission de lumière basée sur la distance SDF pour créer un effet d'explosion. Après tout, les explosions ne sont jamais nombreuses.

Capture d'écran de ShaderToy

All Articles