Box2D in python

Ok, le prochain chapitre de The Nature of Code porte sur les bibliothèques implémentant la physique dans le monde 2d qui nous occupe. Je commence par explorer rapidement la documentation de Box2D. A première vue celà me semble familier, je reconnais des termes comme joint, fixture, shape pour avoir travailler un petit peu avec les moteurs de jeux comme Unity ou Godot. Cependant je cherche désespérément le module dans processing.py et ne trouve que la version java de Box2D. Je me suis habitué à pouvoir faire les exemples de NOC en python, je ne suis pas encore prêt à revenir à Java. Je me lance dans une petite recherche sur Box2D. Je tombe sur une implémentation python de la bibliothèque de simulation physique. En cherchant des exemples, je découvre une autre bibliothèque, Pygame, qui permet de faire tourner le package python pybox2d. Je m’intéresse dès lors à pygame. Bien sûr il s’agit là d’un moteur de jeu, mais détourné à nos fin ce sera un puissant outil de simulation physique. 

  1. Premier programme

Voici mon “Hello World” de la programmation avec pybox2d et pygame : https://github.com/Thibautomaton/SimpleBox2D

Demandons à chatgpt ne nous aider à créer le premier programme pygame-pybox2d le plus simple qui soit. On veut faire tomber un objet (implémentation de la gravité) et que celui-ci rencontre d’autres objets dans sa course (pour voir la gestion des collisions). 

On commence donc par initialiser notre environnement pygame. Le programme commence par : 

SCREEN_WIDTH=1450

SCREEN_HEIGHT=720

PPM = 10.0

TARGET_FPS = 60

TIME_STEP = 1/TARGET_FPS

sub_step_count = 4

pygame.init()

screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))

clock = pygame.time.Clock()

On initialise plusieurs constantes. SCREEN _width et height parlent pour eux même et seront utilisés dans la fonction pygame.display.set_mode() qui est l’equivalent de size(width, height) dans processing, c’est le programme qui crée la fenêtre. PPM et plus loin sub_step_count sont utilisés par pybox2d pour faire sens des données du moteur physique dans l’outil pygame. TARGET_FPS et le nombre de frames que le moteur va essayer de générer lorsqu’il sera en route. TIME_STEP est donc le temps entre chaque frame. 

On a donc pygame.init() et pygame.display.set_mode() qui initialisent l’environnement pygame. 

Puis clock qui va gérer le temps de la simulation en prenant en argument TARGET_FPS.

Ce qui vient après est intéressant, mais pour bien faire sens de ces lignes de code il faut comprendre que la représentation des objets dans pygame et leur localisation effective dans le moteur physique sont séparées. On va donc créer un objet physique avec certaines propriétés (c’est là qu’on utilise les body, fixtures et compagnie), par contre pour pour prendre en compte leur déplacement et leur représentation à l’écran on devra passer par un calcul d’échelle qui rend compte de la taille réelle des objets. 

world = world(gravity=(0, -10), doSleep=True)

ground_body = world.CreateStaticBody(

    position=(100, 2),

    shapes=polygonShape(box=(100, 2))

)

Premièrement on utilise world, comme on a fait l’initialisation de pygame, on a l’initialisation de pybox2d. On voit deux paramètres, gravity qui est donc la gravité, cette fois on a une valeur à peu près véridique de -10 (-9.81 dans la réalité) parce qu’on à une représentation en mètres et en kilos dans pybox2d, et non pas en pixels, c’est pourquoi il faut traduire ces valeurs pour leur représentation à l’écran. 

Dans pybox2d on a la possibilité de créer des objets avec body qui vont prendre au moins l’argument position. Cet objet n’a pas de forme à moins qu’on la précise, ce qui est le cas pour ce StaticBody, pybox2d va générer automatiquement la fixation ce qui ne sera pas le cas sur les autres DynamicBody. Comme son nom l’indique, cet objet n’a pas vocation à être bougé.

circle_body = world.CreateDynamicBody(position=(10, 200))

circle_fixture = circle_body.CreateCircleFixture(radius=1, density=5, friction=0.3, restitution=0.2)

box_body = world.CreateDynamicBody(position=(11, 4))

box_fixture = box_body.CreatePolygonFixture(box=(1, 1), density=1, friction=0.3, restitution=0.5)

box_body_2 = world.CreateDynamicBody(position=(9, 4))

box_fixture_2 = box_body_2.CreatePolygonFixture(box=(1, 1), density=1, friction=0.3, restitution=0.5)

box_body_3 = world.CreateDynamicBody(position=(10, 6))

box_fixture_3 = box_body_3.CreatePolygonFixture(box=(1, 1), density=1, friction=0.3, restitution=0.5)

Ces objets ont une initialisation très similaire. Cette fois on utilise CreateDynamicBody, l’objet à vocation à évoluer. Ici on remarque que pour la position celle-ci ne varie pas grandement d’un objet à l’autre. On se rappelle que dans pybox2d les distance sont évaluées en mètres. Cette fois on a une deuxième fonction qui vient s’initialiser sur l’objet body initialement créé. Dans CreatePolygoneFixture, on vient mettre tous les paramètres qui viennent relier l’objet à la simulation physique. On a donc en premier la forme. Puis la densité qui permet de calculer le poids de l’objet et donc son inertie. La friction qui détermine si l’objet est rugueux ou lisse, et la restitution, compter 1 pour une balle rebondissante sinon il faut jouer avec ce paramètre pour obtenir quelque chose de réaliste.

Maintenant tous ces objets sont ancrés physiquement et sont prêts à interagir d’où ils seront lancés à l’instant 0 de la simulation.

Pour que le programme tourne il faut une boucle, comme le draw() dans processing. On sait déjà à combien de FPS celle-ci va tourner mais voici son code avec pygame : 

running = True

while running:

    for event in pygame.event.get():

        if event.type==pygame.QUIT:

            running=False

 On crée une boucle infinie avec un évènement permettant de sortir de la boucle (ici cliquer sur la croix de la fenêtre), et celà nous permet d’initialiser notre boucle dans laquelle se dérouleront toutes les interactions. 

On commence par : 

screen.fill((0,0,0))

world.Step(TIME_STEP, sub_step_count, 10)

screen.fill donc avec notre display initialisé précédemment est l’équivalent de la fonction background() dans processing. 

La deuxième ligne avec Step, prend en compte le temps entre chaque frame, et initialise les calculs de la simulation physique. Les deux autres arguments sont généralement laissés à quatre et dix, sentez vous libre de demander à chatGPT quels sont les meilleurs paramètres pour votre utilisation. 

Avant de s’attarder sur le reste du code allons à la fin de la boucle : 

clock.tick(TARGET_FPS)

pygame.display.update()

tick est la fonction qui indique à pygame d’afficher le même nombre de FPS. La fonction pygame.display.update() est une autre fonction de pygame qui indique que l’écran doit s’actualiser avec les modifications appliquées puisque c’est l’écran qui est renvoyé à l’utilisateur. 

Les prochains quatre blocs de code sont un peu indigestes et sont une façon de traduire le mouvement des objets en pixels qui vont actualiser notre écran : 

    for fixture in ground_body.fixtures:

        shape = fixture.shape

        vertices = [(ground_body.transform * v)*PPM for v in shape.vertices]

        vertices = [(v[0], SCREEN_HEIGHT – v[1]) for v in vertices]

        pygame.draw.polygon(screen, (255, 0, 0), vertices)

    for fixture in box_body_2.fixtures:

        shape = fixture.shape

        vertices = [(box_body_2.transform * v)*PPM for v in shape.vertices]

        vertices = [(v[0], SCREEN_HEIGHT – v[1]) for v in vertices]

        pygame.draw.polygon(screen, (0, 255, 0), vertices)

    for fixture in box_body_3.fixtures:

        shape = fixture.shape

        vertices = [(box_body_3.transform * v)*PPM for v in shape.vertices]

        vertices = [(v[0], SCREEN_HEIGHT – v[1]) for v in vertices]

        pygame.draw.polygon(screen, (0, 255, 0), vertices)

    for fixture in circle_body.fixtures:

        shape = fixture.shape

        position = circle_body.transform * shape.pos * PPM

        position = (position[0], SCREEN_HEIGHT – position[1])

        pygame.draw.circle(screen, (0, 0, 255), [int(x) for x in position], int(shape.radius*PPM))

    for fixture in box_body.fixtures:

        shape = fixture.shape

        vertices = [(box_body.transform * v)*PPM for v in shape.vertices]

        vertices = [(v[0], SCREEN_HEIGHT-v[1]) for v in vertices]

        pygame.draw.polygon(screen, (0, 255, 0), vertices)

Voici ce que chatgpt a pu me donner en lui demandant de me fournir un code pygame-pybox2d. On va chercher dans fixtures la forme de notre objet et on va essayer de rendre ça à l’écran. Ici on calcule la vrai position et dimension des vertices en utilisant la variable PPM (Pixels per meter) qu’on avait initialisé à 10 plus haut. C’est-à-dire que pour nous 10 pixels représentent 1 mètre dans la simulation. Comme les boîtes ont été initialisées avec un côté de 1 par 1 c’est exactement ce nombre de pixels par boîte que l’on va voir. De même l’axe y est inversé car l’origine de pybox2d se trouve en bas à gauche, quand l’origine de notre écran est en haut à gauche comme pour processing.

On utilise la fonction pygame.draw.circle/polygon en utilisant vertices en argument. La fonction update() plus bas va recevoir toutes ces modifications et les afficher à l’écran pour la prochaine frame. On obtient une forme ronde bleue qui tombe sur 3 carrés verts et les collisions qui en résultent. On a fait tourner notre première simulation physique 2d. Premier pas vers Angry Birds!

  1. Vers les particules

https://github.com/Thibautomaton/SimpleBoxCubesBox2D/tree/main

On veut un programme qui se rapproche de ce ce qu’on a commencé à faire dans processing avec les systèmes de particules. Voyons déjà comment instancier un cube à l’endroit où la  souris clique et le voir interagir avec l’environnement. 

Commençons par la même initialisation que le programme précédent. 

pygame.init()

world = world(gravity =  (0, -10), doSleep = True)

display_surface = pygame.display.set_mode((WIDTH, HEIGHT))

pygame.display.set_caption(‘BoxCubes Recap’)

clock = pygame.time.Clock()

On initialise pygame avec display.set_mode. On ajoute ici un titre à la fenêtre avec la fonction set_caption(). Clock pour timer notre programme. World initialise le monde 2d de pybox2d.
Dans la boucle while on retrouve :  

world.Step(TIMESTEP, 6, 2)

clock.tick(TARGET_FPS)

pygame.display.update()

On reprend le code pour définir le sol : 

ground_body = world.CreateStaticBody(

    position = (104, 0),

    shapes = polygonShape(box=(104, 2))

)

On ne veut pas que nos cubes quittent la simulation, le sol permet d’empêcher ça. 

Dans la boucle while :

    for fixture in ground_body.fixtures:

        shape = fixture.shape

        vertices = [fromWorldToScreen(ground_body.transform*v) for v in shape.vertices]

        pygame.draw.polygon(display_surface, (0, 0, 255), vertices)

    if pygame.mouse.get_pressed()[0]:

        x, y = pygame.mouse.get_pos()

        b = Box(x, y, world)

        boxes.append(b)

Ici on récupère la position dans la fenêtre en coordonnées absolues (de pixels). Et on crée un objet Box avec x, y, et world en arguments. On ajoute ensuite cette Box à une liste. 

Voyons ce que fait l’objet Box dans son initialisateur : 

    def __init__(self,x_, y_, world):

        self.w = 16

        self.h = 16

        self.display_surface = pygame.display.get_surface()

        self.body = world.CreateDynamicBody(

            position = fromScreenToWorld((x_, y_))

        )

        box2dW = scalarScreenToWorld(self.w)

        box2dH = scalarScreenToWorld(self.h)

        ps = polygonShape(box=(box2dW/2, box2dH/2))

        fd = self.body.CreateFixture(

            shape = ps,

            friction = 0.3,

            density = 0.1,

            restitution=0.5

        )

On a donc une dimension w et h définis en pixels, une display_surface qui est l’écran sur lequel vont s’afficher les objets Box. On a un body créé avec la fonction CreateDynamicBody() on lui passe en argument position qui est déterminée à partir des coordonnées en pixels x et y passées en argument de la fonction fromScreenToWorld(), voyons sa définition : 

def fromScreenToWorld(P):

    world_x = P[0]/PPM

    world_y = (HEIGHT-P[1])/PPM

    return vector(world_x, world_y)

On convertit à l’aide de la variable PPM définie. On retourne un vector qui est un Vector2 de la bibliothèque pygame.math et qui nous rapproche de l’utilisation de PVector dans processing. 

Ensuite on définit la shape de notre objet Box. Pour ça on convertit les données des dimensions de la boîte en dimensions compréhensibles par pybox2d. Dernière étape, on crée une fixation avec en données -> la shape, friction, density et restitution.

Maintenant nos objets peuvent être initialisés et interagir les uns avec les autres dans notre simulation physique. Cependant nous ne verrons pas ces interactions si nous ne gardons pas trace de la localisation de ces objets et que nous ne montrons pas ces objets à l’écran.

Dans la boucle principale, on parcourt l’ensemble des objets Box et on appelle la fonction display() : 

    for p in boxes:

        p.display()

La fonction display est définie de la façon suivante : 

    def display(self):

        color = (175, 175, 175)

        border_color = (0,0,0)

        rect_surface = pygame.Surface((self.w, self.h), pygame.SRCALPHA)

        pygame.draw.rect(rect_surface, color, (0,0, self.w, self.h))

        pygame.draw.rect(rect_surface, border_color, (0, 0, self.w, self.h), 2)

        angle_degree = -math.degrees(self.body.transform.angle)

        rotated_surface = pygame.transform.rotate(rect_surface, angle_degree)

        rotated_rect = rotated_surface.get_rect(center = fromWorldToScreen(self.body.transform.position))

        print(self.body.transform.position)

        self.display_surface.blit(rotated_surface, rotated_rect.topleft)

Afin de garder en mémoire la rotation des objets on crée un objet rect_surface qui est une surface pygame, de dimensions les dimensions en pixel de l’objet Box. On dessine un rectangle dans cette surface en coordonnées 0, 0 avec dimensions w et h en donnant la couleur que l’on veut donner à l’objet. 

Ensuite on convertit l’angle de rotation de l’objet dans la physique 2d (self.body.transform.angle) en degrés. On crée une nouvelle surface à laquelle on a appliqué une rotation. Ensuite on récupère rotated_rect dont on a besoin pour afficher l’objet sur l’ecran via la fonction blit. On a déjà vu la définition de la fonction fromWorldToScreen. Blit affiche la surface à l’emplacement que l’on a calculé précédemment. 

generation de boites interagissant avec la physique des objets

CONCLUSION:

Les mondes de la physique 2d et de la simulation pixellisée sont liés dans notre programme. On peut instancier autant d’objets que l’on souhaite, ce qui peut amener à un lag mais sera quand même plus concilient que pour générer de la physique 3d. Cependant il peut être interessant de traquer les objets générés et les enlever du monde Box2D lorsqu’ils ne servent plus. Nous avons réussi à instancier des objets Box2D dans notre programme pygame qui ressemble beaucoup à processing. C’est une très bonne nouvelle puisque celà nous permet de continuer les exemples de NOC. Nous aurons plus tard un interlude sur la création de mini jeux videos pour voir ce que l’on peut faire avec pygame.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Retour en haut