Comme illustré dans gl_brick et gl_brick_shaders, un jeu purement 2D peut être affiché en 3D, comme ce casse brique.
La seule chose qui changera est la partie dessin, la logique restera la même. Pour comparer la version 2D et la version 3D, regardez le code gl_brick_2d.
On fait de la 3D grâce à OpenGL. Attention, le code gl_brick utilise de la vieille 3D (années 2000). On parle d'OpenGL version 2 ou de fixed pipeline ou mode immédiat ou opengl sans shaders. Cependant, OpenGL 2 est très utile pour comprendre les bases de la 3D et les matrices ou pour avoir vite fait un affichage mais nous ne la verrons pas dans ce cours.
Il est vivement conseillé d'apprendre les shaders (OpenGL 3 et 4) dès le début de votre apprentissage OpenGL pour pouvoir faire de puissants effets modernes. Un projet fini avec des shaders ressemble à gl_brick_shaders mais nous verrons pas à pas les concepts dans quelques petits codes plus simples.
Il existe aussi des moteurs de jeux (game engines) qui nous simplifie la vie, comme Unity ou Unreal Engine mais nous ne les verrons pas dans ce cours. Avec OpenGL nous sommes plus près de la machine, nous sommes plus bas niveau et donc on comprends mieux comment les choses marchent. Si vous voulez, OpenGL est ce en quoi sont créés les moteurs de jeux. Cependant ne vous inquiétez pas, OpenGL est raisonnablement facile à apprendre.
Je vais traduire en français ce tutoriel (learnopengl.com) qui est un très bon tutoriel OpenGL. Cependant il s'addresse à des utilisateurs plus avancés et de plus il est en C++.
Pour les concepts de base, je vous guiderez avec une liste de codes, pour les intéressés voici un tutoriel en java par ThinMatrix.
L'avantage est que les fonctions ont le même nom en C++ ou Java ou Python, en effet la bibliothèque OpenGL est une bibliothèque C qui a beaucoup de wrapper dans des languages plus simples (plus haut niveau) comme Python ou Java.
Les fonctions et les concepts OpenGL sont documentés sur le wiki OpenGL.
Nous allons continuer à utiliser pygame
auquel nous allons rajouter
la bibliothèque OpenGL pour travailler en 3D.
Allez sur la page d'installation pour l'installer chez vous.
Avant d'entamer la 3D, je conseille d'être à l'aise avec les fonctions et les objets (Théorie 3).
Tous les codes font un import vecutils
donc vous devez
télécharger vecutils
et le mettre à côté de votre fichier .py
.
Vous pouvez également télécharger gl_3d_basics.zip qui contient tous les fichiers nécessaires de ce chapitre. Ce dossier est également présent sur les ordis de l'école dans le dossier réseau du parascolaire, je vous invite à le copier dans votre propre dossier.
En partant du code de base 2D, voici les étapes à faire:
La correction de ce tutoriel se trouve dans la liste des versions de ma branche 2D_tutorial dans mon dépôt parascolaire-tutorials qui contient plusieurs tutoriels informatiques, ces tutoriels font parties de mon github. Plus d'infos sur le système de correction ici.
Importez PyOpenGL #Correction
from OpenGL.GL import *
from OpenGL.GL import shaders
Si vous avez bien installé OpenGL, il ne devrait pas y avoir d'erreur.
Indiquez que l'écran sera maintenant géré par OpenGL : #Correction
pygame.display.set_mode((512, 512), pygame.OPENGL | pygame.DOUBLEBUF)
Remarquez qu'on n'a plus besoin de faire ecran =
car on ne manipulera plus l'écran directement.
Les couleurs en OpenGL sont de 0 à 1
(voir explications ici),
une couleur qui était par exemple de [255, 153, 0]
sera maintenant de [255/255, 153/255, 0/255]
et donc [1, 0.6, 0]
.
#Correction
L'équivalent OpenGL du ecran.fill
est :
glClearColor(0.9, 0.9, 0.5, 1.0) # du jaune, 1.0 est la transparence
glClear(GL_COLOR_BUFFER_BIT)
Remarquez que vu qu'OpenGL est une grande machine à état, si vous faites glClearColor au début, il s'en souviendra jusqu'à ce qu'elle change.
Vu que nous faisons un rendu OpenGL, vous devez enlever les appels pygame.draw
.
#Correction
Si vous lancez votre code vous devriez avoir un écran jaune.
La partie dessin sera un ensemble de shader program, chaque shader program contient au moins 2 shaders, le vertex shader, et le fragment shader.
Un shader c'est un petit fichier de code, codé en langage GLSL, il sera exécuté en parallèle sur la carte graphique !
Chaque shader program peut dessiner (render) un ou plusieurs VAO, chaque VAO trouve ses données dans un VBO.
Un VBO est simplement une liste de nombre, un VAO dit comment sont structurés ces nombres.
La partie dessin sera donc une série de sélectionner un shader program, sélectionner le VAO, dessiner. #Correction
glUseProgram(shader_program)
glBindVertexArray(vertex_array_object)
# ici on fera un dessin opengl utilisant le vao et le shader program
glBindVertexArray(0)
glUseProgram(0)
Vous devriez avoir deux erreurs en insérant ce code, shader_program
et vertex_array_object
n'étant pas défini.
Mais qu'est ce qu'on dessine en 3D ? C'est des points qui sont assemblés en triangles ! Mais d'où viennent ces points ? Ils viennent du VBO et passent par le VAO !
Au début du code, on va créer un VBO qui contient les positions 2D de nos 3 points.
Ici, on est dans le repère de base de OpenGL, le NDC (Normalized Device Coordinates).
Le point en bas à gauche est (-1, -1)
.
Le point en haut à droite est (+1, +1)
.
Le centre de l'écran sera donc (0, 0)
,
l'axe x part vers la droite,
l'axe y part vers le haut,
l'axe z rentre dans l'écran et part derrière votre écran.
On a donc trois points dont les XY valent (0.6, 0.6), (−0.6, 0.6) et (0, −0.6). La coordonnée en Z sera de 0 (on la changera plus tard), et la dernière coordonnée (appelée W) sera toujours de 1. Pour en savoir plus sur W, voir la page de math (translation). #Correction
Bien que dans le futur nous utiliserons plutôt un repère main droite (dextrogyre), le repère de base OpenGL est main gauche (lévogyre).
Quand nous serons vraiment en 3D, nous utiliserons la convention dextrogyre de Z vers le haut. D'autres personnes (comme ThinMatrix, ou dans le jeu Minecraft) utilisent la convention Y vers le haut.
La carte graphique veut un farray
, il faut donc faire la conversion.
Le f veut dire que la liste contient des nombres à virgule flottante sur 32 bits
(voir définition de farray
dans vecutils), c'est ce que la carte graphique veut.
Vu que farray
est dans vecutils
, faites bien attention d'avoir écrit
import vecutils
, d'avoir téléchargé
vecutils
et de l'avoir mis à côté de votre fichier .py
.
Pour pouvoir écrire farray
et non vecutils.farray
, lisez le fichier progra_import,
vous comprendrez donc pourquoi il faut écrire from vecutils import farray
ou from vecutils import *
.
On a défini des points en RAM, il est temps de les mettre dans un VBO de la carte graphique !
Toujours au début du code, on va donc créer un VBO.
vertex_buffer = glGenBuffers(1)
On veut un seul buffer, sinon on aurait pu faire a,b,c = glGenBuffers(3)
par exemple.
On dit à OpenGL qu'on va le manipuler donc on le bind, à la fin on le détachera avec unbind, parfois j'aime dire qu'on le sélectionne et puis qu'on le désélectionne.
glBindBuffer(GL_ARRAY_BUFFER, vertex_buffer) # on sélectionne le vbo "vertex_buffer"
# manipulation vbo
glBindBuffer(GL_ARRAY_BUFFER, 0) # aucun vbo sélectionné
On envoie les nombres dans le VBO ! #Correction
Placez ce code dans la partie manipulation vbo
:
glBufferData(GL_ARRAY_BUFFER, 48, vertices, GL_STATIC_DRAW) # 48 bytes
D'où vient ce 48 ? C'est le nombre d'octets (bytes) qu'on envoie, en fait, chaque nombre dans un farray
fait 4 octets (32 bits),
ce qui fait donc 4 × 12 = 48 octets envoyés.
Cependant on pourrait aussi faire ceci pour ne pas devoir calculer le nombre d'octets : #Correction
glBufferData(GL_ARRAY_BUFFER, ArrayDatatype.arrayByteCount(vertices), vertices, GL_STATIC_DRAW) # 48 bytes
Finalement, le GL_STATIC_DRAW
est un indice donné à la carte graphique disant que ces données ne changeront jamais.
Les données, les 48 bytes, sont dans la carte graphique, on doit maintenant donner plus d'infos, ce ne sont pas des bytes, ce sont des coordonnées pardi ! Direction VAO !
On crée un VAO, et on spécifie le type de données : #Correction
vertex_array_object = glGenVertexArrays(1)
glBindVertexArray(vertex_array_object) # on sélectionne le vao "vertex_array_object"
# manipulation vao
glBindVertexArray(0) # aucun vao sélectionné
Et puis on informe du type de donnée, attention le VBO doit être binded.
#Correction
Placez ce code dans la partie manipulation vao
.
glEnableVertexAttribArray(0) # on active l'attribut 0
glVertexAttribPointer(0, 4, GL_FLOAT, False, 0, ctypes.c_void_p(0)) # données par groupe de 4 Float dans l'attribut 0
farray
.
Ça fait donc 4 GL_FLOAT.
Comme dit précédemment, on va créer un shader program et l'utiliser dans le dessin.
Au début du code on fait donc : #Correction
vertex_shader = '''
// contenu du vertex shader
'''
fragment_shader = '''
// contenu du fragment shader
'''
shader_program = shaders.compileProgram(
shaders.compileShader(vertex_shader, GL_VERTEX_SHADER),
shaders.compileShader(fragment_shader, GL_FRAGMENT_SHADER))
Si vous lancez votre code vous devriez avoir une erreur car les shaders sont vides.
En GLSL, les commentaires sont via //
et non #
.
Maintenant que vous savez la différence entre shader et shader program, je risque de dire shader pour les deux.
Et dans la partie dessin, on comprends maintenant que sont ces shader et vao !
La partie dessin de votre programme sera donc comme ceci : #Correction
glUseProgram(shader_program)
glBindVertexArray(vertex_array_object)
glDrawArrays(GL_TRIANGLES, 0, 3)
glBindVertexArray(0)
glUseProgram(0)
L'appel à glDrawArrays
est expliqué comme ceci :
On va (enfin !) passer au contenu des 2 shaders.
Le vertex shader est appelé une fois par point (vertex au singulier, vertices au pluriel).
Le vertex shader...
[input]
Lit les attributs du point.
[output]
Calcule la position finale du point, gl_Position
.
Ici, on n'a rien à faire, on lit l'attribut envoyé, et on l'écrit dans gl_Position
.
#Correction
Remplacez le commentaire // contenu du vertex shader
par :
#version 330
// vertex shader
in vec4 position; // on lit le xyzw
void main() {
gl_Position = position; // et on l'écrit
}
Attention, n'enlevez pas le #version 330
, on est en GLSL et donc ce n'est pas un commentaire !
Ensuite nous avons la liste des in
et out
de notre shader, actuellement, il n'y a qu'un in
.
Ensuite nous écrivons void main() {
,
cette syntaxe inspirée du langage C veut dire
le calcul commence ici et finit quand l'accolade est fermée,
le code en dehors du main
n'est que descriptif.
Le seul calcul qu'on fait ici n'en n'est pas vraiment un, on lit l'entrée position
et on l'écrit dans la sortie gl_Position
.
Finalement nous fermons l'accolade ouverte précédemment en écrivant }
Le vertex shader peut également calculer d'autres infos qui seront passées au fragment shader, ces valeurs seront interpolées, on verra ça plus tard.
Si vous lancez votre code vous devriez avoir une erreur car nous n'avons pas encore écrit de fragment shader.
OpenGL a assemblé nos trois points, et crée un triangle, le frament shader sera appelé une fois par pixel de ce triangle.
Le fragment shader...
[input]
Lit les infos calculées dans le vertex shader (les out
du vertex shader).
[output]
Calcule la couleur finale du pixel dans sa variable out
.
Ici, on va juste dire que le pixel est orange, à 100% de transparence. #Correction
Remplacez le commentaire // contenu du fragment shader
par :
#version 330
// fragment shader
out vec4 pixel; // notre but est de donner la couleur du pixel
void main() {
pixel = vec4(1, 0.5, 0, 1); // orange, transparence 100%
}
N'essayez pas de juste changer la valeur de la transparence, il faut activer d'autres choses pour ça (glEnable(GL_BLEND)
est un bon début).
Finalement, il faut que l'attribut dans le VAO correspond à l'attribut 0 dans le shader, soit on l'indique dans le vertex shader via location
:
// vertex shader
layout (location = 0) in vec4 position; // on lit le xyzw
Ou bien on peut utiliser glGetAttribLocation
dans le code python pour savoir le numéro qui a été assigné automatiquement :
#Correction
position = glGetAttribLocation(shader_program, 'position')
glEnableVertexAttribArray(position) # on active l'attribut position
glVertexAttribPointer(position, 4, GL_FLOAT, False, 0, ctypes.c_void_p(0)) # données par groupe de 4 Float dans l'attribut position
Attention, parfois cet attribut est supprimé quand il n'est pas utilisé, et glGetAttribLocation
renvoie -1
#Correction
position = glGetAttribLocation(shader_program, 'position')
if position != -1:
glEnableVertexAttribArray(position) # on active l'attribut position
glVertexAttribPointer(position, 4, GL_FLOAT, False, 0, ctypes.c_void_p(0)) # données par groupe de 4 Float dans l'attribut position
else:
print('inactive attribute "{}"'.format('position'))
Votre code devrait maintenant enfin afficher quelque chose, le triangle sur la figure ci-dessous, mais ne vous inquiétez pas, la vraie 3D arrive dans les exercices pour rajouter de la perspective, à la fin des exercices on aura un résulat comme les petits oiseaux ci-dessous !
Vous pouvez maintenant passez aux exercices ou lire les annexes ci-dessous !
La suite d'opération vertex shader, fragment shader est appelé le pipeline graphique. Il est fait en parallèle sur la carte graphique, si on a 30 points et 30 unités sur la carte graphique, alors ça prendra autant de temps d'avoir 1 point que 30. Le vertex shader sera appelé en parallèle pour les 30 points en même temps.
On peut rajouter plein d'étapes sur le pipeline, le vertex shader et fragment shader étant le minimum.
La vidéo de ThinMatrix explique assez bien:
Un petit mot sur l'interpolation.
Si par exemple, le point 1 envoie l'info 1, le point 2 envoie l'info 2 et le point 3 envoie l'info 3, quelle info va recevoir le pixel ?
Et bien, ça dépendra de sa distance 3D par rapport aux points, au plus le pixel est proche du point, au plus il prendra sa valeur. Ainsi un pixel proche du point 1 recevra l'info 1. Un pixel à égale distance des points 1, 2 et 3 recevra valeur ⅓ 1 + ⅓ 2 + ⅓ 3 = 2. Le pixel au milieu des points 1 et 2 recevra la valeur ½ 1 + ½ 2 = 1.5.
Le premier exercice fera ça pour les couleurs et on obtiendra ceci !
Le calcul n'est pas difficile à faire et utilise les coordonnées barycentriques du triangle.
Le code de base pour ce niveau se trouve dans gl_shaders, une version structurée avec des fonctions est disponible dans gl_shaders_with_functions. Ces exercices peuvent être fait dans n'importe quel ordre.
vertices
, mais il faut également changer d'autres valeurs, lesquelles ?
vec3 couleur
, il faudra donc refaire :
glGenBuffers
pour avoir de la place sur la carte graphique,
glBufferData
pour y mettre des données,
glEnableVertexAttribArray
pour que les données soient envoyées,
glVertexAttribPointer
pour que le shader comprennent que nous envoyons 3 floats.
in vec3 couleur
et l'envoyer sous le nom out fcouleur
, j'ai choisi le nom fcouleur pour couleur envoyée dans le fragment shader.
in vec3 fcouleur
et l'envoyer dans le out vec4 pixel
,
vous pouvez utiliser vec4(fcouleur, 1)
pour copier les 3 composantes r,g,b et mettre la 4ème composante (alpha) à 1.
-1
, 1
, 2
ou -3
?
Vous devriez remarquer que lorsque z n'est pas entre -1 et 1, le triangle n'est plus visible,
en effet c'est similaire au fait que lorsque le x ou le y de tous les points ne sont pas entre -1 et 1, l'objet n'est pas visible.
vertices
pour avoir vertices = farray([0.6, 0.6, 0, -0.6, 0.6, 0, 0.0, -0.6, 0])
.
glVertexAttribPointer
.
in vec3 position
au lieu du in vec4 position
).
gl_Position = vec4(position, 1)
.
uniform vec3 translation
.
position + translation
.
loc_translation = glGetUniformLocation(shader_program, 'translation')
glUniform3f(loc_translation, 0.5, 0.2, 0)
Ou :
translation = farray((0.5, 0.2, 0))
loc_translation = glGetUniformLocation(shader_program, 'translation')
glUniform3fv(loc_translation, 1, translation)
0.5
et 0.7
, laquelle des deux flèches voyez-vous ?
glEnable(GL_DEPTH_TEST)
,
à chaque frame faites maintenant glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
à la place de glClear(GL_COLOR_BUFFER_BIT)
et recommencez les deux expériences précédentes, que voyez-vous ?
uniform float scale
.
position * scale
,
en effet si j'ai un triangle défini par les points
{(0.6, 0.6), (−0.6, 0.6), (0.0, −0.6)},
et que je multiplie chaque point par 2,
j'aurai le triangle {(1.2, 1.2), (−1.2, 1.2), (0.0, −1.2)} qui est 2 fois plus grand.
glUniform1f(loc, 2.0)
pour la flèche 2 fois plus grande.
Nous allons maintenant détailler le calcul de la position d'un point 3D vers un point 2D.
Voici une très belle image qui résume les opérations et le résultat du code que nous ferons.
Veuillez lire la page math pour être à l'aise avec le concept de vecteur et de matrices. Je ferai une vidéo qui explique les matrices de projection et de caméra (LookAt).
Avec les matrices comme succession de transformation, il est très facile par exemple d'animer les hélices (propeller) d'un hélicoptère :
La correction se trouve dans gl_matrices_and_interpolation.
Le code ci-dessus qui fait appel n'est pas très opengl moderne, un autre exemple de séquence de matrices de transformation est dans ce magnifique tutoriel sur l'animation de squelette de ThinMatrix.
Correction dans gl_textures. [learnopengl] [ThinMatrix]
Shader programming: light, diffuse, normals, specular... Je vous conseille d'être à l'aise avec le produit scalaire. [learnopengl] [ThinMatrix].