Après avoir maîtrisé les bases des shaders, nous utilisons une approche pratique pour exploiter la puissance du GPU afin de créer un éclairage réaliste et dynamique..
La première partie de cette série couvrait les bases des shaders graphiques. La deuxième partie explique la procédure générale de configuration des shaders pour servir de référence à la plate-forme de votre choix. À partir de maintenant, nous aborderons des concepts généraux sur les shaders graphiques sans adopter de plate-forme spécifique. (Par souci de commodité, tous les exemples de code continueront d'utiliser JavaScript / WebGL.)
Avant d’aller plus loin, assurez-vous d’avoir le moyen d’exécuter des shaders avec lesquels vous êtes à l’aise. (JavaScript / WebGL est peut-être le plus simple, mais je vous encourage à suivre sur votre plate-forme préférée!)
À la fin de ce didacticiel, vous pourrez non seulement acquérir une solide connaissance des systèmes d’éclairage, mais vous en aurez construit un vous-même..
Voici à quoi ressemble le résultat final (cliquez pour basculer les lumières):
Vous pouvez créer et éditer ceci sur CodePen.Bien que de nombreux moteurs de jeu proposent des systèmes d'éclairage prêts à l'emploi, comprendre comment ils sont fabriqués et comment créer les vôtres vous donne beaucoup plus de flexibilité pour créer un look unique qui correspond à votre jeu. Les effets de shader ne doivent pas non plus être purement cosmétiques, ils peuvent ouvrir la voie à de nouveaux mécanismes fascinants.!
Chroma en est un excellent exemple. le personnage du joueur peut parcourir les ombres dynamiques créées en temps réel:
Nous allons sauter une bonne partie de la configuration initiale, car c’était l’objet exclusif du précédent tutoriel. Nous allons commencer avec un fragment fragment simple rendant notre texture:
Vous pouvez créer et éditer ceci sur CodePen.Rien d'extraordinaire ne se passe ici. Notre code JavaScript configure notre scène et envoie la texture à restituer, ainsi que les dimensions de notre écran, au shader..
var uniforms = tex: type: 't', valeur: texture, // La texture res: type: 'v2', valeur: new THREE.Vector2 (window.innerWidth, window.innerHeight) // Conserve La résolution
Dans notre code GLSL, nous déclarons et utilisons ces uniformes:
échantillonneur uniforme2D tex; uniforme vec2 res; void main () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D (tex, pixel); gl_FragColor = couleur;
Nous nous assurons de normaliser nos coordonnées en pixels avant de les utiliser pour dessiner la texture.
Pour vous assurer de bien comprendre tout ce qui se passe ici, voici un défi de mise en train:
Défi: Pouvez-vous rendre la texture tout en conservant son format d'image? (Essayez vous-même; nous allons parcourir la solution ci-dessous.)
La raison pour laquelle il est étiré devrait être assez évidente, mais voici quelques astuces: Regardez la ligne où nous normalisons nos coordonnées:
vec2 pixel = gl_FragCoord.xy / res.xy;
Nous divisons un vec2
par un vec2
, ce qui revient à diviser chaque composant individuellement. En d'autres termes, ce qui précède équivaut à:
vec2 pixel = vec2 (0.0.0.0); pixel.x = gl_FragCoord.x / res.x; pixel.y = gl_FragCoord.y / res.y;
Nous divisons nos x et y par des nombres différents (la largeur et la hauteur de l'écran), de sorte qu'il sera naturellement étendu.
Que se passerait-il si on divisait le x et le y de gl_FragCoord
par juste le x res
? Ou qu'en est-il juste le y?
Par souci de simplicité, nous allons conserver notre code de normalisation tel quel pour le reste du didacticiel, mais il est bon de comprendre ce qui se passe ici.!
Avant de pouvoir faire quelque chose d'extraordinaire, nous avons besoin d'une source de lumière. Une "source de lumière" n'est rien d'autre qu'un point que nous envoyons à notre shader. Nous allons construire un nouvel uniforme pour ce point:
var uniforms = // Ajoutez notre variable light ici light: type: 'v3', valeur: new THREE.Vector3 (), tex: type: 't', valeur: texture, // la texture res: type: 'v2', valeur: new THREE.Vector2 (window.innerWidth, window.innerHeight) // Conserve la résolution
Nous avons créé un vecteur à trois dimensions parce que nous voulons utiliser le X
et y
comme le position de la lumière sur l'écran, et la z
comme le rayon.
Définissons quelques valeurs pour notre source de lumière en JavaScript:
uniforms.light.value.z = 0.2; // notre rayon
Nous avons l'intention d'utiliser le rayon sous forme de pourcentage des dimensions de l'écran, afin 0,2
serait 20% de notre écran. (Ce choix n'a rien de spécial. Nous aurions pu définir une taille en pixels. Ce nombre ne signifie rien tant que nous n'avons pas modifié le code GLSL.)
Pour obtenir la position de la souris en JavaScript, il suffit d'ajouter un écouteur d'événement:
document.onmousemove = function (event) // Mettez à jour la source de lumière pour qu'elle suive notre souris uniformes.light.value.x = event.clientX; uniforms.light.value.y = event.clientY;
Écrivons maintenant du code shader pour utiliser ce point lumineux. Nous allons commencer par une tâche simple: Nous voulons que chaque pixel de notre plage de lumière soit visible et tout le reste soit noir..
Traduire cela en GLSL pourrait ressembler à ceci:
échantillonneur uniforme2D tex; uniforme vec2 res; uniform vec3 light; // N'oubliez pas de déclarer l'uniforme ici! void main () vec2 pixel = gl_FragCoord.xy / res.xy; vec4 color = texture2D (tex, pixel); // Distance du pixel actuel par rapport à la position de la lumière float dist = distance (gl_FragCoord.xy, light.xy); if (light.z * res.x> dist) // Vérifiez si ce pixel est sans la plage gl_FragColor = color; else gl_FragColor = vec4 (0.0);
Tout ce que nous avons fait ici est:
Euh oh! Quelque chose semble éteint avec la façon dont la lumière suit la souris.
Défi: Pouvez-vous résoudre ce problème? (Encore une fois, essayez vous-même avant de traverser ci-dessous.)
Vous vous souviendrez peut-être du premier tutoriel de cette série que l’axe des ordonnées est inversé. Vous pourriez être tenté de simplement faire:
light.y = res.y - light.y;
Ce qui est mathématiquement correct, mais si vous faites cela, votre shader ne compilera pas! Le problème est que les variables uniformes ne peuvent pas être changées.Pour voir pourquoi, souviens-toi que ce code runs pour chaque pixel en parallèle. Imaginez tous ces cœurs de processeur essayant de modifier une seule variable en même temps. Pas bon!
Nous pouvons résoudre ce problème en créant une nouvelle variable au lieu d'essayer de modifier notre uniforme. Ou mieux encore, nous pouvons simplement faire cette étape avant en le passant au shader:
Vous pouvez créer et éditer ceci sur CodePen.uniforms.light.value.y = window.innerHeight - event.clientY;
Nous avons maintenant défini avec succès la plage visible de notre scène. Ça a l'air très net, cependant…
Au lieu de simplement couper au noir lorsque nous sommes en dehors de la plage, nous pouvons essayer de créer un dégradé régulier vers les bords. Nous pouvons le faire en utilisant la distance que nous calculons déjà.
Au lieu de définir tous les pixels de la plage visible sur la couleur de la texture, procédez comme suit:
gl_FragColor = couleur;
On peut le multiplier par un facteur de la distance:
gl_FragColor = color * (1.0 - dist / (light.z * res.x));Vous pouvez créer et éditer ceci sur CodePen.
Cela fonctionne parce que dist
est la distance en pixels entre le pixel actuel et la source de lumière. Le terme (light.z * res.x)
est la longueur du rayon. Alors, quand on regarde le pixel exactement à la source de lumière, dist
est 0
, alors on finit par se multiplier Couleur
par 1
, qui est la couleur.
dist
est calculé pour certains pixels arbitraires. dist
est différent en fonction du pixel auquel nous nous trouvons, alors que light.z * res.x
est constant.Quand on regarde un pixel au bord du cercle, dist
est égal à la longueur du rayon, donc nous finissons par multiplier Couleur
par 0
, qui est noir.
Jusqu'ici, nous n'avons pas fait beaucoup plus que créer un masque dégradé pour notre texture. Tout a l'air encore appartement. Pour comprendre comment résoudre ce problème, voyons ce que notre système d’éclairage fait actuellement, par opposition à ce qu’il est. supposé faire.
Dans le scénario ci-dessus, vous vous attendriez à UNE pour être le plus éclairé, puisque notre source de lumière est directement au-dessus, avec B et C être sombre, puisque presque aucun rayon de lumière ne frappe réellement les côtés.
Cependant, voici ce que notre système d'éclairage actuel voit:
Ils sont tous traités de manière égale, car le seul facteur que nous prenons en compte est distance sur le plan xy.Vous pensez peut-être que tout ce dont nous avons besoin maintenant est la hauteur de chacun de ces points, mais ce n’est pas tout à fait vrai. Pour voir pourquoi, considérons ce scénario:
UNE est le sommet de notre bloc, et B et C sont les côtés de celui-ci. ré est une autre parcelle de terrain à proximité. On peut voir ça UNE et ré devrait être le plus brillant, avec ré être un peu plus sombre parce que la lumière l'atteint de biais. B et C, d'autre part, devrait être très sombre, car presque aucune lumière ne les atteint, car ils sont éloignés de la source de lumière.
Ce n'est pas tellement la hauteur la direction à laquelle la surface fait facedont nous avons besoin. Ceci est appelé le surface Ordinaire.
Mais comment transmet-on cette information au shader? Nous ne pouvons pas éventuellement envoyer un tableau géant de milliers de nombres pour chaque pixel, pouvons-nous? En fait, nous le faisons déjà! Sauf que nous ne l'appelons pas un tableau, nous l'appelons un texture.
C'est exactement ce qu'est une carte normale. c'est juste une image où le r
, g
et b
les valeurs de chaque pixel représentent une direction au lieu d'une couleur.
Ci-dessus, une simple carte normale. Si nous utilisons un sélecteur de couleur, nous pouvons voir que la direction par défaut "à plat" est représentée par la couleur (0.5, 0.5, 1)
(la couleur bleue qui occupe la majorité de l'image). C'est la direction qui pointe vers le haut. Les valeurs x, y et z sont mappées sur les valeurs r, g et b.
Le côté incliné à droite pointe vers la droite, donc sa valeur x est plus élevée; la valeur x est également sa valeur rouge, raison pour laquelle elle a l'air plus rougeâtre / rosâtre. La même chose s'applique à tous les autres côtés.
Cela a l'air drôle parce que ce n'est pas censé être rendu; il est fait uniquement pour encoder les valeurs de ces normales de surface.
Alors chargeons cette simple carte normale à tester avec:
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg" var normal = THREE.ImageUtils.loadTexture (normalURL);
Et ajoutez-le comme l'une de nos variables uniformes:
var uniforms = norm: type: 't', valeur: normal, //… le reste de nos données ici
Pour vérifier que nous l'avons chargé correctement, essayons de le rendre à la place de notre texture en modifiant notre code GLSL (rappelez-vous, nous l'utilisons simplement comme texture d'arrière-plan, plutôt que comme une carte normale, à ce stade):
Vous pouvez créer et éditer ceci sur CodePen.Maintenant que nous avons nos données normales de surface, nous devons mettre en place un modèle d'éclairage. En d’autres termes, nous devons indiquer à notre surface comment prendre en compte tous les facteurs dont nous disposons pour calculer la luminosité finale..
Le modèle de Phong est le plus simple que nous puissions mettre en œuvre. Voici comment cela fonctionne: Avec une surface avec des données normales comme celle-ci:
Nous calculons simplement l'angle entre la source de lumière et la normale à la surface:
Plus cet angle est petit, plus le pixel est lumineux.
Cela signifie que les pixels situés directement sous la source de lumière, où la différence d’angle est égale à 0, seront les plus brillants. Les pixels les plus sombres seront ceux pointant dans la même direction que le rayon lumineux (ce serait comme le dessous de l'objet)
Maintenant, implémentons ceci.
Puisque nous utilisons une simple carte normale pour tester, définissons notre texture sur une couleur unie afin que nous puissions facilement savoir si cela fonctionne..
Donc, au lieu de:
vec4 color = texture2D (…);
Faisons-en un blanc uni (ou n'importe quelle couleur que vous aimez vraiment):
couleur vec4 = vec4 (1,0); // blanc uni
C’est un raccourci GLSL pour créer un vec4
avec toutes les composantes égales à 1,0
.
Voici à quoi ressemble notre algorithme:
Nous devons savoir dans quelle direction se trouve la surface pour pouvoir calculer la quantité de lumière qui doit atteindre ce pixel. Cette direction est stockée dans notre carte normale, donc obtenir notre vecteur normal signifie simplement obtenir la couleur de pixel actuelle de la texture normale:
vec3 NormalVector = texture2D (norme, pixel) .xyz;
Puisque la valeur alpha ne représente rien dans la carte normale, nous n'avons besoin que des trois premiers composants.
Maintenant, nous devons savoir dans quelle direction notre lumière est pointant. Nous pouvons imaginer que notre surface de lumière est une lampe de poche placée devant l'écran, à l'emplacement de notre souris, afin de pouvoir calculer le vecteur direction de la lumière en utilisant simplement la distance entre la source de lumière et le pixel:
vec3 LightVector = vec3 (light.x - gl_FragCoord.x, light.y - gl_FragCoord.y, 60,0);
Il doit également avoir une coordonnée z (pour pouvoir calculer l'angle par rapport au vecteur normal de surface à 3 dimensions). Vous pouvez jouer avec cette valeur. Vous constaterez que plus il est petit, plus le contraste est net entre les zones claires et sombres. Vous pouvez considérer cela comme la hauteur à laquelle vous tenez votre lampe de poche au-dessus de la scène. plus elle est éloignée, plus la lumière est distribuée uniformément.
Maintenant pour normaliser:
NormalVector = Normaliser (NormalVector); LightVector = normaliser (LightVector);
Nous utilisons la fonction intégrée normaliser pour nous assurer que nos deux vecteurs ont une longueur de 1,0
. Nous devons le faire car nous sommes sur le point de calculer l'angle à l'aide du produit scalaire. Si vous êtes un peu flou sur la façon dont cela fonctionne, vous voudrez peut-être améliorer certaines de vos algèbres linéaires. Pour nos besoins, il vous suffit de savoir que le produit scalaire renverra le cosinus de l'angle entre deux vecteurs de même longueur.
Continuons avec la fonction dot intégrée:
float diffuse = dot (NormalVector, LightVector);
je l'appelle diffuser juste parce que c'est ce que ce terme est appelé dans le modèle d'éclairage Phong, en raison de la façon dont il dicte combien de lumière atteint la surface de notre scène.
C'est tout! Maintenant, vas-y et multiplie ta couleur par ce terme. Je suis allé de l'avant et a créé une variable appelée distanceFactor
de sorte que notre équation semble plus lisible:
float distanceFactor = (1.0 - dist / (light.z * res.x)); gl_FragColor = color * diffuse * distanceFactor;
Et nous avons un modèle d'éclairage fonctionnel! (Vous voudrez peut-être élargir le rayon de votre lumière pour voir l'effet plus clairement.)
Vous pouvez créer et éditer ceci sur CodePen.Hmm, quelque chose semble un peu éteint. On a l'impression que notre lumière est inclinée.
Revoyons nos calculs pendant une seconde ici. Nous avons ce vecteur lumière:
vec3 LightVector = vec3 (light.x - gl_FragCoord.x, light.y - gl_FragCoord.y, 60,0);
Ce que nous savons va nous donner (0, 0, 60)
lorsque la lumière est directement au-dessus de ce pixel. Après normalisation, ce sera (0, 0, 1)
.
Rappelez-vous que nous voulons une normale qui pointe directement vers la lumière pour avoir la luminosité maximale. Notre surface normale par défaut, dirigée vers le haut, est (0.5, 0.5, 1)
.
Défi: Pouvez-vous voir la solution maintenant? Pouvez-vous mettre en œuvre?
Le problème est que vous ne pouvez pas stocker de nombres négatifs en tant que valeurs de couleur dans une texture. Vous ne pouvez pas désigner un vecteur pointant vers la gauche en tant que (-0,5, 0, 0)
. Ainsi, les personnes qui créent des cartes normales doivent ajouter 0.5
à tout. (Ou, plus généralement, ils doivent changer leur système de coordonnées). Vous devez être conscient de cela pour savoir que vous devez soustraire 0.5
de chaque pixel avant d'utiliser la carte.
Voici à quoi ressemble la démo après soustraction 0.5
à partir des x et y de notre vecteur normal:
Il y a une dernière solution que nous devons faire. Rappelez-vous que le produit scalaire renvoie le cosinus de l'angle. Cela signifie que notre sortie est bloquée entre -1 et 1. Nous ne voulons pas de valeurs négatives dans nos couleurs, et bien que WebGL semble ignorer automatiquement ces valeurs négatives, vous pourriez avoir un comportement étrange ailleurs. Nous pouvons utiliser la fonction max intégrée pour résoudre ce problème, en tournant ceci:
float diffuse = dot (NormalVector, LightVector);
Dans ceci:
float diffuse = max (point (NormalVector, LightVector), 0.0);
Maintenant, vous avez un modèle d'éclairage qui fonctionne!
Vous pouvez rétablir la texture des pierres et trouver sa vraie carte normale dans le dépôt GitHub pour cette série (ou directement ici):
Nous n'avons besoin que de changer une ligne JavaScript, à partir de:
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/normal_test.jpg"
à:
var normalURL = "https://raw.githubusercontent.com/tutsplus/Beginners-Guide-to-Shaders/master/Part3/normal_maps/blocks_normal.JPG"
Et une ligne GLSL, parmi:
vec4 color = vec4 (1.0); // blanc uni
N'ayant plus besoin du blanc uni, nous tirons la vraie texture, comme suit:
vec4 color = texture2D (tex, pixel);
Et voici le résultat final:
Vous pouvez créer et éditer ceci sur CodePen.Le GPU est très efficace, mais il est utile de savoir ce qui peut le ralentir. Voici quelques conseils à ce sujet:
Une chose à propos des shaders est qu’il est généralement préférable d’éviter ramification dès que possible. Bien que vous ayez rarement à vous soucier d'un tas de si
instructions sur n'importe quel code que vous écrivez pour le processeur, elles peuvent constituer un goulot d'étranglement majeur pour le GPU.
Pour voir pourquoi, rappelez-vous à nouveau que votre code GLSL runs sur chaque pixel de l'écran en parallèle. La carte graphique permet de nombreuses optimisations car tous les pixels doivent exécuter les mêmes opérations. S'il y a un tas de si
Toutefois, certaines de ces optimisations pourraient commencer à échouer, car différents pixels exécuteront désormais un code différent. Que ce soit ou non si
les déclarations qui ralentissent les choses semblent dépendre du matériel spécifique et de la mise en oeuvre de la carte graphique, mais il est bon de garder à l'esprit lorsque vous tentez d'accélérer votre shader.
C'est un concept très utile lorsqu'il s'agit de l'éclairage. Imaginez si nous voulions avoir deux sources de lumière, trois ou une douzaine; nous aurions besoin de calculer l'angle entre chaque surface normale et chaque point de lumière. Cela va rapidement ralentir notre shader. Rendu différé est un moyen d’optimiser cela en divisant le travail de notre shader en plusieurs passes. Voici un article qui décrit en détail ce que cela signifie. Je citerai la partie pertinente pour notre propos ici:
L'éclairage est la raison principale pour choisir un itinéraire par rapport à l'autre. Dans un pipeline de rendu direct standard, les calculs d'éclairage doivent être effectués sur chaque sommet et sur chaque fragment de la scène visible., pour chaque lumière de la scène.
Par exemple, au lieu d'envoyer un tableau de points lumineux, vous pouvez les dessiner tous sur une texture, sous forme de cercles, avec la couleur à chaque pixel représentant l'intensité de la lumière. De cette façon, vous serez capable de calculer l’effet combiné de toutes les lumières de votre scène et d’envoyer la texture finale (ou le tampon comme on l’appelle parfois) pour calculer l’éclairage.
Apprendre à scinder le travail en plusieurs passes pour le shader est une technique très utile. Les effets de flou utilisent cette idée pour accélérer le shader, par exemple, ainsi que des effets comme un shader fluide / fumée. Cela sort du cadre de ce tutoriel, mais nous pourrions revoir cette technique dans un futur tutoriel.!
Maintenant que vous avez un shader d'éclairage en état de marche, voici quelques éléments à essayer:
z
valeur) du vecteur lumière pour voir son effetLa texture des pierres et la carte normale utilisées dans ce tutoriel sont extraites d'OpenGameArt:
http://opengameart.org/content/50-free-textures-4-normalmaps
De nombreux programmes peuvent vous aider à créer des cartes normales. Si vous souhaitez en savoir plus sur la création de vos propres cartes normales, cet article peut vous aider..