Alcuni appunti su Bullet e raylib

In passato mi è già capitato di parlare di Bullet, lo avevo usato qualche volta tanto tempo fa, tanto che ne avevo fatto anche due-tre articoli nel 2012!

Da allora molte cose sono cambiate, tranne le API di Bullet, che almeno superficialmente sono più o meno sempre quelle, e l’interesse che sporadicamente rinasce in me per quella libreria.

Una cosa che è cambiata è che ho cominciato a lavorare su un software di modellazione 3D, e in particolare mi occupo della parte dei plugin, di fare esempi a scopo di documentazione e cose del genere. Così avevo avuto l’idea di provare a scriverne uno di integrazione con Bullet.

L’idea alla fine non è partita, ma a me era rimasta la voglia di sperimentare un po’ con questo motore, e in particolare anche vedere come si comporta con la parte di softbody.

Così nelle ultime due settimane ho un po’ lavorato a questi esempi e avevo l’idea di farne anche alcuni carini, ma mi sa che metterò un po’ in pausa l’idea, quindi intanto volevo condividere qui alcune delle cose che ho imparato.

Per fare i miei esperimenti volevo una libreria che fosse molto semplice e veloce per renderizzare scene 3D dinamiche, senza però doversi mettere a interagire direttamente con OpenGL.

La mia scelta è ricaduta su raylib: il suo esempio per disegnare un cubo in una scena 3D richiede una quarantina di righe. Integrarci Bullet aggiunge un centinaio di righe, tra inizializzazione, finalizzazione, creazione della forma, aggiornamenti del corpo rigido, etc.

Per questo motivo non riporterò il codice intero qui, ma soprattutto alla fine ho creato una serie di classi che mi aiutassero per fare le demo, uno dei motivi per cui non mi trovo troppo bene con gli esempi ufficiali di Bullet 🙈 .

Lo schema generale

Lo schema generale di una demo è abbastanza semplice:

  1. inizializzazione del motore di rendering: creazione della finestra e della telecamera;
  2. inizializzazione di Bullet: creazione del mondo e di tutti gli oggetti. Bullet è molto modulare, quindi permette di fare delle scelte per esempio sul risolutore degli urti o sul componente che cerca nella scena quali corpi potrebbero interagire tra di loro;
  3. creazione della scena: caricamento/creazione degli oggetti da renderizzare e creazione dei corpi fisici da associare loro;
  4. ciclo di rendering: avanzamento della simulazione fisica, aggiornamento degli oggetti, rendering degli oggetti e del debug della fisica in 3D, più una serie di informazioni in 2D;
  5. finalizzazione della fisica: distruzione di tutti gli oggetti C++ associati a Bullet;
  6. finalizzazione del motore di rendering: chiusura della finestra.

Una cosa che ho trovato un po’ noiosa nella creazione dei modelli è il fatto che la trasformazione iniziale per Bullet è lunga da impostare: per applicare anche una rotazione, è necessario prima creare un quaternione. Se, come me, non siete troppo ferrati con queste strutture matematiche, allora avrete la necessità di creare un quaternione, chiamare un qualche metodo per impostare la rotazione (e.g. asse e angolo), chiamarne un altro per applicare il quaternione alla struttura di trasformazione. Per fare tutte queste cose con vari vettori sto davvero iniziando ad apprezzare l’inizializzazione mediante parentesi graffe.

Per lo stepping della simulazione fisica occorre avere l’intervallo di tempo tra un frame e l’altro: raylib ha la comoda funzione GetFrameTime() ottima per lo scopo.

L’aggiornamento dei modelli invece dipende dal tipo di corpo. Per i corpi rigidi, Bullet offre la loro nuova trasformazione, che può essere applicata quasi direttamente a raylib, la quale non lo scrive esplicitamente nella documentazione, ma dà la possibilità di applicare una trasformazione al modello con il campo transform della struct Model. L’unico problema è che Bullet e raylib usano un modo diverso di memorizzare tale matrice, quindi alla fine serve una trasposizione. Personalmente ho preferito farla hardcoded, siccome sono solo 16 elementi, e lasciare al compilatore il compito di fare ottimizzazioni.

Entrino i softbody

L’implementazione dei softbody di Bullet è stata curata (almeno inizialmente) da Nathanael Presson, e non da Erwin Coumans, e si vede un po’ la differenza di stile.

Uno dei problemi è che la documentazione non è conforme allo stile di Doxygen, quindi mi sono trovato spesso ad alternare la documentazione web al codice. È un peccato, perché di per sé commenti ce ne sono abbastanza, inoltre il mondo di questi corpi è sicuramente più complesso di quello dei corpi rigidi, quindi avere una documentazione forte sarebbe veramente di aiuto.

Probabilmente anche avere una base teorica sull’argomento sarebbe di aiuto, in quanto ci sono davvero tanti parametri, la documentazione dice a cosa corrispondono, ma non a cosa servono. Mi piacerebbe fare in futuro provare creare una GUI per provarli un po’, però, per fare una cosa del genere, la struttura dello scheletro di cui sopra diventerebbe più complessa.

Per i softbody, tutto il sistema di forme dei corpi rigidi viene a mancare, anche perché comunque potrebbero cambiare. Ogni corpo invece ha una struttura a grafo, i cui nodi sono i vertici, ma i collegamenti non sono le facce, ma una struttura che usa Bullet per fare i suoi conti internamente. Abilitando il debug, si possono vedere infatti che ci sono molti più collegamenti di quelli che servono per creare le facce, o almeno questa è stata la mia interpretazione.

È comunque possibile ottenere in maniera abbastanza semplice le informazioni per il rendering: ogni nodo contiene le coordinate dei vertici e le normali, con cui si può creare quindi un vertex buffer:

const auto &nodes = body->m_nodes;
mesh.vertexCount = nodes.size();
vertices.resize(mesh.vertexCount * 3);
normals.resize(vertices.size());
for (int i = 0, j = 0; i < nodes.size(); i++) {
	vertices[j] = nodes[i].m_x.x();
	normals[j++] = nodes[i].m_n.x();
	vertices[j] = nodes[i].m_x.y();
	normals[j++] = nodes[i].m_n.y();
	vertices[j] = nodes[i].m_x.z();
	normals[j++] = nodes[i].m_n.z();
}

Anche un index buffer è facilmente ottenibile: i softbody hanno anche un altro contenitore, per le facce, ciascuna un triangolo che fa riferimento tramite puntatore al contenitore dei nodi. Di fatto, l’indice si ottiene sottraendo il puntatore del primo nodo:

const auto *node0 = &nodes[0];
const auto &faces = mBody->m_faces;
mesh.triangleCount = faces.size();
indices.resize(mesh.triangleCount * 3);
for (int i = 0, j = 0; i < faces.size(); i++) {
	indices[j++] = faces[i].m_n[0] - node0;
	indices[j++] = faces[i].m_n[1] - node0;
	indices[j++] = faces[i].m_n[2] - node0;
}

Penso che Bullet non cambi mai i triangoli, ma solo i nodi, quindi in linea di massima non ci dovrebbe mai essere bisogno di aggiornare il buffer degli indici.

La conseguenza immediata da questa struttura è che le facce grandi non sono il massimo per la simulazione. Se facce più piccole migliorano il risultato, rendono però i calcoli più onerosi. Non ho verificato quale sia la dipendenza, ma è una cosa che ho trovato anche quando mi sono informato sui softbody.

raylib supporta mesh con indici a 16bit, quindi poco più di 20000 triangoli. Per le demo non penso sia un problema troppo grande, ma per altre applicazioni reali magari sì.

E sempre a proposito di raylib, la libreria precompilata contiene diverse funzioni in più rispetto all’header con cui viene, tra queste anche le funzioni per caricare e aggiorare i buffer sulla GPU. Quindi è possibile creare delle mesh e modelli personalizzati, ma non funzioneranno. La soluzione è di scaricare anche l’header rlgl.h direttamente dal repo di raylib, che mette a disposizione le funzioni rlLoadMesh e rlUpdateMesh.

Dai miei pochi test ho verificato alcune cose strane con i softbody. La prima è che non rispettano il piano infinito che si può mettere per arrestare i rigidbody. Un’altra è che talvolta c’è qualche altra imprecisione anche nell’interazione con altri rigid body, per esempio in uno dei miei primi esempi si vede come il corpo che simula un tessuto compenetri nella scatola, anziché rimanere solo sopra. Non so se sia una risoluzione del tessuto troppo bassa, o qualche problema con i parametri, vedrò di indagare.

Un’altra cosa da approfondire sarebbero i constraint, che sembrano essere la vera forza dei softbody; Xuchen Han, studente alla UCLA, li sta portando parecchio avanti e lo sviluppo, almeno a giudicare dal repo, sembra molto attivo. Magari potrebbero essere protagonisti dei miei prossimi esperimenti.

Intanto vi lascio il download dei miei esperimenti, se mai potessero essere utili a qualcuno. Li rilascio nel pubblico dominio.