Wolfenstein 3D: lancer de rayons avec WebGL1

image

Après l'apparition des cartes graphiques Nvidia RTX l'été dernier, le lancer de rayons a retrouvé son ancienne popularité. Au cours des derniers mois, mon flux Twitter a été rempli d'un flux sans fin de comparaisons graphiques avec RTX activé et désactivé.

Après avoir admiré tant de belles images, j'ai voulu essayer de combiner le rendu classique classique avec un traceur de rayons par moi-même.

Souffrant du syndrome de rejet des développements des autres , j'ai donc créé mon propre moteur de rendu hybride basé sur WebGL1. Vous pouvez jouer avec le rendu du niveau de démonstration de Wolfenstein 3D avec des sphères (que j'ai utilisé en raison du lancer de rayons) ici .

Prototype


J'ai commencé ce projet en créant un prototype, en essayant de recréer l'éclairage global avec le lancer de rayons de Metro Exodus .


Le premier prototype à montrer un éclairage global diffus (Diffuse GI)

Le prototype est basé sur un moteur de rendu vers l'avant, qui rend la géométrie entière de la scène. Le shader utilisé pour pixelliser la géométrie non seulement calcule l'illumination directe, mais émet également des rayons aléatoires à partir de la surface de la géométrie rendue pour s'accumuler à l'aide du traceur de rayons réflexion indirecte de la lumière provenant de surfaces non brillantes (GI diffus).

Dans l'image ci-dessus, vous pouvez voir comment toutes les sphères sont correctement éclairées uniquement par un éclairage indirect (les rayons lumineux sont réfléchis par le mur derrière la caméra). La source de lumière elle-même est recouverte d'un mur brun sur le côté gauche de l'image.

Wolfenstein 3D


Le prototype utilise une scène très simple. Il n'a qu'une seule source de lumière et seules quelques sphères et cubes sont rendus. Grâce à cela, le code de lancer de rayons dans le shader est très simple. Le cycle de vérification de la force brute de l'intersection dans lequel le faisceau est testé pour l'intersection avec tous les cubes et sphères de la scène est encore assez rapide pour que le programme l'exécute en temps réel.

Après avoir créé ce prototype, je voulais faire quelque chose de plus complexe en ajoutant plus de géométrie et beaucoup de sources lumineuses à la scène.

Le problème avec un environnement plus complexe est que j'ai encore besoin de pouvoir tracer les rayons de la scène en temps réel. Habituellement, une structure de hiérarchie de volume englobante (BVH) serait utilisée pour accélérer le processus de lancer de rayons, mais ma décision de créer ce projet sur WebGL1 ne le permettait pas: il est impossible de charger des données 16 bits dans une texture dans WebGL1 et les opérations binaires ne peuvent pas être utilisées dans un shader. Cela complique le calcul préliminaire et l'application de BVH dans les shaders WebGL1.

C'est pourquoi j'ai décidé d'utiliser le niveau de démonstration Wolfenstein 3D pour cela. En 2013, j'ai créé un shader WebGL fragmenté dans Shadertoy qui rend non seulement les niveaux de Wolfenstein, mais crée également toutes les textures nécessaires de manière procédurale. D'après mon expérience de travail sur ce shader, je savais que la conception de niveaux basée sur la grille de Wolfenstein peut également être utilisée comme structure d'accélération rapide et facile, et que le lancer de rayons sur cette structure sera très rapide.

Vous trouverez ci-dessous une capture d'écran de la démo, et en mode plein écran, vous pouvez la lire ici: https://reindernijhoff.net/wolfrt .


Brève description


La démo utilise un moteur de rendu hybride. Pour rendre tous les polygones du cadre, il utilise la tramage traditionnelle, puis combine le résultat avec des ombres, une IG diffuse et des réflexions créées par le lancer de rayons.


Ombres


Diffuse gi


Réflexions

Rendu proactif


Les cartes Wolfenstein peuvent être entièrement encodées dans une grille bidimensionnelle 64 × 64. La carte utilisée dans la démo est basée sur le premier niveau de l'épisode 1 de Wolfenstein 3D.

Au démarrage, toute la géométrie nécessaire pour passer le rendu proactif est créée. Un maillage de murs est généré à partir des données cartographiques. Il crée également des plans de plancher et de plafond, des maillages séparés pour les lumières, les portes et des sphères aléatoires.

Toutes les textures utilisées pour les murs et les portes sont regroupées dans un seul atlas de texture, de sorte que tous les murs peuvent être dessinés en un seul appel.

Ombres et éclairage


L'éclairage direct est calculé dans le shader utilisé pour la passe de rendu vers l'avant. Chaque fragment peut être éclairé (au maximum) par quatre sources différentes. Pour savoir quelles sources peuvent influencer le fragment dans le shader, lorsque la démo démarre, la texture de recherche est précalculée. Cette texture de recherche a une taille de 64 par 128 et code les positions des 4 sources lumineuses les plus proches pour chaque position dans la grille de la carte.

varying vec3 vWorldPos; varying vec3 vNormal; void main(void) { vec3 ro = vWorldPos; vec3 normal = normalize(vNormal); vec3 light = vec3(0); for (int i=0; i<LIGHTS_ENCODED_IN_MAP; i++) { light += sampleLight(i, ro, normal); } 

Pour obtenir des ombres douces pour chaque fragment et source lumineuse, une position aléatoire dans la source lumineuse est échantillonnée. En utilisant le code de traçage des rayons dans le shader (voir la section Traçage des rayons ci-dessous), un rayon d'ombre est émis au point d'échantillonnage pour déterminer la visibilité de la source de lumière.

Après avoir ajouté des réflexions (auxiliaires) (voir la section Réflexion ci-dessous), le GI diffus est ajouté à la couleur calculée du fragment en effectuant une recherche dans la cible de rendu du GI diffus (voir ci-dessous).

Ray tracing


Bien que le prototype de code de traçage des rayons pour l'IG diffuse ait été combiné avec un shader préemptif, dans la démo, j'ai décidé de les séparer.


Je les ai séparés en faisant un deuxième rendu de toute la géométrie dans une cible de rendu séparée (Diffuse GI Render Target) en utilisant un autre shader qui n'émet que des rayons aléatoires pour collecter le GI diffus (voir la section «Diffuse GI» ci-dessous). L'éclairage collecté dans cette cible de rendu est ajouté à l'éclairage direct calculé dans le passage de rendu vers l'avant.

En séparant la passe proactive et l'IG diffus, nous pouvons émettre moins d'un faisceau IG diffus par pixel d'écran. Cela peut être fait en réduisant l'échelle du tampon (en déplaçant le curseur dans les options dans le coin supérieur droit de l'écran).

Par exemple, si l'échelle du tampon est de 0,5, alors un seul rayon sera émis pour quatre pixels d'écran. Cela permet une énorme augmentation de la productivité. En utilisant la même interface utilisateur dans le coin supérieur droit de l'écran, vous pouvez également modifier le nombre d'échantillons par pixel dans la cible de rendu (SPP) et le nombre de réflexions du faisceau.

Emettre un faisceau


Afin de pouvoir émettre des rayons dans la scène, tous les niveaux de géométrie doivent avoir un format que le traceur de rayons du shader peut utiliser. La couche Wolfenstein codait une grille 64 × 64, il est donc assez facile de coder toutes les données en une seule texture 64 × 64:

  • Dans le canal rouge de la couleur de texture, tous les objets situés dans la cellule correspondante x, y de la grille de carte sont codés. Si la valeur du canal rouge est nulle, il n'y a aucun objet dans la cellule, sinon, il est occupé par un mur (valeurs de 1 à 64), une porte, une source de lumière ou une sphère qui doit être vérifiée pour l'intersection.
  • Si une sphère occupe une cellule de grille de niveau, les canaux vert, bleu et alpha sont utilisés pour coder le rayon et les coordonnées x et y relatives de la sphère à l'intérieur de la cellule de grille.

Un rayon est émis dans une scène en parcourant une texture à l'aide du code suivant:

 bool worldHit(n vec3 ro,in vec3 rd,in float t_min, in float t_max, inout vec3 recPos, inout vec3 recNormal, inout vec3 recColor) { vec3 pos = floor(ro); vec3 ri = 1.0/rd; vec3 rs = sign(rd); vec3 dis = (pos-ro + 0.5 + rs*0.5) * ri; for( int i=0; i<MAXSTEPS; i++ ) { vec3 mm = step(dis.xyz, dis.zyx); dis += mm * rs * ri; pos += mm * rs; vec4 mapType = texture2D(_MapTexture, pos.xz * (1. / 64.)); if (isWall(mapType)) { ... return true; } } return false; } 

Un code de traçage de rayons maillé similaire peut être trouvé dans ce shader Wolfenstein sur Shadertoy.

Après avoir calculé le point d'intersection avec le mur ou la porte (en utilisant le test d'intersection avec un parallélogramme ), la recherche dans le même atlas de texture qui a été utilisé pour passer le rendu proactif nous donne des points d'intersection d'albédo. Les sphères ont une couleur qui est déterminée par la procédure en fonction de leurs coordonnées x, y dans la grille et de la fonction de dégradé de couleurs .

Les portes sont un peu plus compliquées car elles bougent. Pour que la représentation de la scène dans le CPU (utilisée pour rendre les maillages dans la passe de rendu vers l'avant) soit la même que la représentation de la scène dans le GPU (utilisée pour le lancer de rayons), toutes les portes se déplacent automatiquement et de manière déterministe, en fonction de la distance entre la caméra et la porte.



Diffuse gi


L'éclairage global diffusé (IG diffus) est calculé en émettant des rayons dans le shader, qui est utilisé pour dessiner toute la géométrie dans la cible de rendu de l'IG diffus. La direction de ces rayons dépend de la normale à la surface, déterminée en échantillonnant l'hémisphère pondéré en cosinus.

Ayant la direction du faisceau rd et le point de départ ro , l'éclairage réfléchi peut être calculé en utilisant le cycle suivant:

 vec3 getBounceCol(in vec3 ro, in vec3 rd, in vec3 col) { vec3 emitted = vec3(0); vec3 recPos, recNormal, recColor; for (int i=0; i<MAX_RECURSION; i++) { if (worldHit(ro, rd, 0.001, 20., recPos, recNormal, recColor)) { // if (isLightHit) { // direct light sampling code // return vec3(0); // } col *= recColor; for (int i=0; i<2; i++) { emitted += col * sampleLight(i, recPos, recNormal); } } else { return emitted; } rd = cosWeightedRandomHemisphereDirection(recNormal); ro = recPos; } return emitted; } 

Pour réduire le bruit, un échantillonnage de lumière directe est ajouté à la boucle. Ceci est similaire à la technique utilisée dans mon encore un autre shader Cornell Box sur Shadertoy.

La réflexion


Grâce à la possibilité de tracer la scène avec des rayons dans le shader, il est très facile d'ajouter des reflets. Dans ma démo, des réflexions sont ajoutées en appelant la même méthode getBounceCol montrée ci-dessus en utilisant le faisceau réfléchi de la caméra:

 #ifdef REFLECTION col = mix(col, getReflectionCol(ro, reflect(normalize(vWorldPos - _CamPos), normal), albedo), .15); #endif 

Des réflexions sont ajoutées dans la passe de rendu avant, par conséquent, un rayon de réflexion émettra toujours un faisceau de réflexion.


Anticrénelage temporel


Étant donné que les ombres douces du passage de rendu proactif et l'approximation GI diffuse utilisent environ un échantillon par pixel, le résultat final est extrêmement bruyant. Pour réduire la quantité de bruit, l'anti-crénelage temporel (TAA) a été utilisé sur la base du TAA de Playdead: Anti-aliasing de reprojection temporelle dans INSIDE .

Re-projection


L'idée derrière TAA est assez simple: TAA calcule un sous-pixel par image, puis fait la moyenne de ses valeurs avec le pixel corrélatif de l'image précédente.

Pour savoir où se trouvait le pixel actuel dans l'image précédente, la position du fragment est re-projetée à l'aide de la matrice de projection de vue modèle de l'image précédente.

Supprimer des échantillons et limiter les quartiers


Dans certains cas, un échantillon enregistré dans le passé n'est pas valide, par exemple, lorsque la caméra s'est déplacée de telle manière qu'un fragment de l'image actuelle dans l'image précédente a été fermé par la géométrie. Pour supprimer ces échantillons non valides, une restriction de voisinage est utilisée. J'ai choisi le type de restriction le plus simple:

 vec3 history = texture2D(_History, uvOld ).rgb; for (float x = -1.; x <= 1.; x+=1.) { for (float y = -1.; y <= 1.; y+=1.) { vec3 n = texture2D(_New, vUV + vec2(x,y) / _Resolution).rgb; mx = max(n, mx); mn = min(n, mn); } } vec3 history_clamped = clamp(history, mn, mx); 

J'ai également essayé d'utiliser la méthode de restriction basée sur le parallélogramme de délimitation, mais je n'ai pas vu beaucoup de différence avec ma solution. Cela est probablement dû au fait que dans la scène de la démo, il existe de nombreuses couleurs sombres identiques et presque aucun objet en mouvement.

Vibrations de la caméra


Pour obtenir l'anticrénelage, la caméra dans chaque image oscille en raison de l'utilisation d'un décalage (pseudo) aléatoire de sous-pixels. Ceci est mis en œuvre en changeant la matrice de projection:

 this._projectionMatrix[2 * 4 + 0] += (this.getHaltonSequence(frame % 51, 2) - .5) / renderWidth; this._projectionMatrix[2 * 4 + 1] += (this.getHaltonSequence(frame % 41, 3) - .5) / renderHeight; 

Le bruit


Le bruit est la base des algorithmes utilisés pour calculer l'IG diffus et les ombres douces. L'utilisation d'un bon bruit affecte considérablement la qualité de l'image, tandis qu'un mauvais bruit crée des artefacts ou ralentit la convergence de l'image.

J'ai peur que le bruit blanc utilisé dans cette démo ne soit pas très bon.

L'utilisation d'un bon bruit est probablement l'aspect le plus important de l'amélioration de la qualité d'image dans cette démo. Par exemple, vous pouvez utiliser du bruit bleu .

J'ai mené des expériences avec du bruit basé sur le nombre d'or, mais elles ont échoué. Jusqu'à présent, le tristement célèbre Hash sans Sine de Dave Hoskins est utilisé:

 vec2 hash2() { vec3 p3 = fract(vec3(g_seed += 0.1) * HASHSCALE3); p3 += dot(p3, p3.yzx + 19.19); return fract((p3.xx+p3.yz)*p3.zy); } 


Réduction du bruit


Même avec TAA activé, la démo montre toujours beaucoup de bruit. Il est particulièrement difficile de rendre le plafond, car il n'est éclairé que par un éclairage indirect. Cela ne simplifie pas la situation où le plafond est une grande surface plane, remplie d'une couleur unie: s'il avait une texture ou des détails géométriques, le bruit deviendrait moins perceptible.

Je ne voulais pas passer beaucoup de temps sur cette partie de la démo, j'ai donc essayé d'appliquer un seul filtre de réduction du bruit: Median3x3 de Morgan McGuire et Kyle Witson . Malheureusement, ce filtre ne fonctionne pas très bien avec les graphiques "pixel art" des textures de mur: il supprime tous les détails à distance et arrondit les coins des pixels des murs voisins.

Dans une autre expérience, j'ai appliqué le même filtre à la cible de rendu GI diffus. Bien qu'il ait légèrement réduit le bruit, en même temps presque sans changer les détails des textures des murs, j'ai décidé que cette amélioration ne valait pas les millisecondes supplémentaires dépensées.

Démo


Vous pouvez jouer la démo ici .

Source: https://habr.com/ru/post/fr444516/


All Articles