libGDX Android: Game Loop e Rendering con OpenGL ES – Parte 2

libGDX Android con OpenGL ES in Java – Parte 2: Game Loop, forme geometriche, input touch e performance

In questo secondo articolo della serie su libGDX Android con OpenGL ES in Java entriamo nel vivo: analizziamo il game loop, disegniamo forme geometriche con OpenGL ES (senza SpriteBatch), gestiamo l’input touch e ottimizziamo le performance. Diamo per scontato che tu abbia già completato il setup del progetto, l’AndroidLauncher con GLSurfaceView, gli shader di base e la comunicazione thread-safe con Gdx.app.postRunnable spiegati nel primo articolo. Per approfondire il framework, consulta la documentazione ufficiale di libGDX.

libGDX game loop Android con OpenGL ES – diagramma del ciclo di rendering

Se ti interessano anche altri esempi completi in OpenGL ES Java su Android, vedi le guide correlate: OpenGL ES su Android in Java, il gioco Pong, Arkanoid e la guida generale su come creare un gioco Android in Java.

1. libGDX game loop Android: delta time e rendering

In libGDX il game loop è gestito dal framework: non devi creare manualmente un ciclo while(true). Il metodo render() del tuo ApplicationListener (o ApplicationAdapter) viene chiamato ad ogni frame dal thread di rendering OpenGL.

Il parametro fondamentale è il delta time, ovvero il tempo (in secondi) trascorso tra il frame precedente e quello corrente. Lo ottieni con Gdx.graphics.getDeltaTime(). Usare il delta time permette movimenti indipendenti dal frame rate: la logica di gioco viene scalata in base al tempo reale, non al numero di frame.

Se sposti un oggetto così:

x += 5; // pixel per frame (sbagliato)

su un dispositivo a 60 FPS andrà più veloce che a 30 FPS. La soluzione corretta è:

x += velocitaPixelAlSecondo * deltaTime; // corretto

Esempio pratico: movimento lineare nel game loop libGDX Android

Immagina una classe di gioco principale che estende ApplicationAdapter (come nel primo articolo). Aggiungiamo un quadrato che si muove orizzontalmente, usando il delta time:

public class MyGame extends ApplicationAdapter {
private float squareX;
private float squareY;
private float speed = 200f; // pixel al secondo

@Override
public void create() {
squareX = 0f;
squareY = 200f; // posizione verticale fissa per l'esempio
}

@Override
public void render() {
float delta = Gdx.graphics.getDeltaTime();

// logica di movimento: indipendente dal frame rate
squareX += speed * delta;

// se esce dallo schermo, ricomincia da sinistra
if (squareX > Gdx.graphics.getWidth()) {
squareX = -50f; // consideriamo la larghezza del quadrato
}

// qui effettuerai il rendering OpenGL del quadrato
// usando squareX e squareY come posizione
}
}

In questo articolo ci concentriamo su come disegnare realmente il quadrato (e un cerchio) con OpenGL ES tramite libGDX.

2. Disegnare forme geometriche con libGDX e OpenGL ES Android

Nel primo articolo hai già visto come impostare un semplice shader GLSL e usare GL20. Ora passiamo a un esempio più strutturato con:

  • VAO e VBO per memorizzare i vertici
  • un rettangolo colorato che si muove usando il delta time
  • un cerchio approssimato con un triangle fan

Shader GLSL di base per libGDX Android

Useremo uno shader molto semplice con posizione 2D e colore per vertice.

Vertex shader (simple.vert):

#version 100
attribute vec2 a_position;
attribute vec4 a_color;

uniform mat4 u_projView;

varying vec4 v_color;

void main() {
v_color = a_color;
gl_Position = u_projView * vec4(a_position, 0.0, 1.0);
}

Fragment shader (simple.frag):

#version 100
precision mediump float;

varying vec4 v_color;

void main() {
gl_FragColor = v_color;
}

Le stringhe degli shader verranno caricate in Java (da file interne di assets o stringhe in codice) e passate alla nostra classe ShaderHelper che vedremo a breve.

Setup Java: VAO/VBO nel game loop libGDX Android

Nel seguente esempio abbiamo:

  • un rettangolo definito da due triangoli (6 vertici)
  • un cerchio definito come triangle fan (N+2 vertici)
  • uso di FloatBuffer pre-allocati (nessuna allocazione nel render loop)

public class ShapeRendererGame extends ApplicationAdapter {
private int programId;
private int aPositionLocation;
private int aColorLocation;
private int uProjViewLocation;

private int rectVaoId;
private int rectVboId;
private int rectVertexCount;

private int circleVaoId;
private int circleVboId;
private int circleVertexCount;

private float squareX;
private float squareY;
private float squareSpeed = 200f; // pixel al secondo

private float[] projViewMatrix = new float[16];

@Override
public void create() {
// compila shader e crea programma
String vertexSrc = Gdx.files.internal("shaders/simple.vert").readString();
String fragmentSrc = Gdx.files.internal("shaders/simple.frag").readString();
programId = ShaderHelper.createProgram(vertexSrc, fragmentSrc);

GL20 gl = Gdx.gl20;

// recupera location degli attributi/uniform
aPositionLocation = gl.glGetAttribLocation(programId, "a_position");
aColorLocation = gl.glGetAttribLocation(programId, "a_color");
uProjViewLocation = gl.glGetUniformLocation(programId, "u_projView");

// inizializza posizione del quadrato
squareX = 0f;
squareY = 200f;

// crea geometrie
createRectangle(gl);
createCircle(gl);

// abilita blending per colori con alpha (se serve)
gl.glEnable(GL20.GL_BLEND);<br gl.glBlendFunc(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA);
}

private void createRectangle(GL20 gl) {
// rettangolo 100x50 in coordinate mondo (pixel) con colore rosso
float width = 100f;
float height = 50f;

float x0 = 0f;
float y0 = 0f;
float x1 = x0 + width;
float y1 = y0 + height;

// ogni vertice: x, y, r, g, b, a (6 float)
rectVertexCount = 6; // 2 triangoli
float[] vertices = new float[rectVertexCount * 6];

int idx = 0;
// triangolo 1 (x0,y0) (x1,y0) (x1,y1)
idx = putVertex(vertices, idx, x0, y0, 1f, 0f, 0f, 1f);
idx = putVertex(vertices, idx, x1, y0, 1f, 0f, 0f, 1f);
idx = putVertex(vertices, idx, x1, y1, 1f, 0f, 0f, 1f);

// triangolo 2 (x0,y0) (x1,y1) (x0,y1)
idx = putVertex(vertices, idx, x0, y0, 1f, 0f, 0f, 1f);
idx = putVertex(vertices, idx, x1, y1, 1f, 0f, 0f, 1f);
idx = putVertex(vertices, idx, x0, y1, 1f, 0f, 0f, 1f);

// crea buffer nativo
FloatBuffer vertexBuffer = ByteBuffer.allocateDirect(vertices.length * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer();
vertexBuffer.put(vertices).position(0);

// genera VAO e VBO
int[] tmp = new int[1];
gl.glGenVertexArrays(1, tmp, 0);
rectVaoId = tmp[0];
gl.glBindVertexArray(rectVaoId);

gl.glGenBuffers(1, tmp, 0);
rectVboId = tmp[0];
gl.glBindBuffer(GL20.GL_ARRAY_BUFFER, rectVboId);
gl.glBufferData(GL20.GL_ARRAY_BUFFER, vertices.length * 4,
vertexBuffer, GL20.GL_STATIC_DRAW);

int stride = 6 * 4; // 6 float per vertice * 4 byte
gl.glEnableVertexAttribArray(aPositionLocation);
gl.glVertexAttribPointer(aPositionLocation, 2, GL20.GL_FLOAT,
false, stride, 0);

gl.glEnableVertexAttribArray(aColorLocation);
gl.glVertexAttribPointer(aColorLocation, 4, GL20.GL_FLOAT,
false, stride, 2 * 4);

gl.glBindVertexArray(0);
gl.glBindBuffer(GL20.GL_ARRAY_BUFFER, 0);
}

private void createCircle(GL20 gl) {
// cerchio approssimato come triangle fan
float centerX = 300f;
float centerY = 300f;
float radius = 60f;
int segments = 40; // più segmenti = cerchio più liscio

circleVertexCount = segments + 2; // centro + N+1 punti sull'anello
float[] vertices = new float[circleVertexCount * 6];

int idx = 0;
// centro (colore verde)
idx = putVertex(vertices, idx, centerX, centerY, 0f, 1f, 0f, 1f);

// punti sul bordo
for (int i = 0; i <= segments; i++) {
float angle = (float) (2 * Math.PI * i / segments);
float x = centerX + (float) Math.cos(angle) * radius;
float y = centerY + (float) Math.sin(angle) * radius;
idx = putVertex(vertices, idx, x, y, 0f, 1f, 0f, 1f);
}

FloatBuffer vertexBuffer = ByteBuffer.allocateDirect(vertices.length * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer();
vertexBuffer.put(vertices).position(0);

int[] tmp = new int[1];
gl.glGenVertexArrays(1, tmp, 0);
circleVaoId = tmp[0];
gl.glBindVertexArray(circleVaoId);

gl.glGenBuffers(1, tmp, 0);
circleVboId = tmp[0];
gl.glBindBuffer(GL20.GL_ARRAY_BUFFER, circleVboId);
gl.glBufferData(GL20.GL_ARRAY_BUFFER, vertices.length * 4,
vertexBuffer, GL20.GL_STATIC_DRAW);

int stride = 6 * 4;
gl.glEnableVertexAttribArray(aPositionLocation);
gl.glVertexAttribPointer(aPositionLocation, 2, GL20.GL_FLOAT,
false, stride, 0);

gl.glEnableVertexAttribArray(aColorLocation);
gl.glVertexAttribPointer(aColorLocation, 4, GL20.GL_FLOAT,
false, stride, 2 * 4);

gl.glBindVertexArray(0);
gl.glBindBuffer(GL20.GL_ARRAY_BUFFER, 0);
}

private int putVertex(float[] array, int idx,
float x, float y,
float r, float g, float b, float a) {
array[idx++] = x;
array[idx++] = y;
array[idx++] = r;
array[idx++] = g;
array[idx++] = b;
array[idx++] = a;
return idx;
}

@Override
public void resize(int width, int height) {
// imposta viewport e matrice di proiezione ortografica in pixel
GL20 gl = Gdx.gl20;
gl.glViewport(0, 0, width, height);

// proiezione ortografica: (0,0) in basso a sinistra, (width,height) in alto a destra
Matrix4 proj = new Matrix4().setToOrtho2D(0, 0, width, height);
System.arraycopy(proj.val, 0, projViewMatrix, 0, 16);
}

@Override
public void render() {
GL20 gl = Gdx.gl20;

float delta = Gdx.graphics.getDeltaTime();
// aggiorna logica del quadrato in base al delta time
squareX += squareSpeed * delta;
if (squareX > Gdx.graphics.getWidth()) {
squareX = -100f; // larghezza del rettangolo
}

// pulizia dello schermo (colore di sfondo nero)
gl.glClearColor(0f, 0f, 0f, 1f);
gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

// usa programma shader
gl.glUseProgram(programId);

// imposta la matrice di proiezione
gl.glUniformMatrix4fv(uProjViewLocation, 1, false, projViewMatrix, 0);

// disegna cerchio (fermo)
gl.glBindVertexArray(circleVaoId);
gl.glDrawArrays(GL20.GL_TRIANGLE_FAN, 0, circleVertexCount);

// disegna rettangolo (traslato in X/Y)
// per semplicità aggiorniamo la matrice di proiezione con una traduzione
Matrix4 proj = new Matrix4().setToOrtho2D(0, 0,
Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
proj.translate(squareX, squareY, 0f);
proj.get(projViewMatrix);
gl.glUniformMatrix4fv(uProjViewLocation, 1, false, projViewMatrix, 0);

gl.glBindVertexArray(rectVaoId);
gl.glDrawArrays(GL20.GL_TRIANGLES, 0, rectVertexCount);

// unbind
gl.glBindVertexArray(0);
gl.glUseProgram(0);
}

@Override
public void dispose() {
GL20 gl = Gdx.gl20;
int[] tmp = new int[1];

tmp[0] = rectVboId;
gl.glDeleteBuffers(1, tmp, 0);
tmp[0] = circleVboId;
gl.glDeleteBuffers(1, tmp, 0);

tmp[0] = rectVaoId;
gl.glDeleteVertexArrays(1, tmp, 0);
tmp[0] = circleVaoId;
gl.glDeleteVertexArrays(1, tmp, 0);

gl.glDeleteProgram(programId);
}
}

Il codice sopra mostra un uso minimale di VAO/VBO per forme 2D. In un gioco reale aggiungeresti una matrice di modello separata per posizione/rotazione/scala dei singoli oggetti, ma il concetto di base rimane lo stesso.

3. Classe riutilizzabile ShaderHelper per libGDX Android

Per non ripetere codice di compilazione/link degli shader, creiamo una classe helper riutilizzabile che:

  • compila vertex e fragment shader
  • stampa gli errori di compilazione/link
  • lancia un’eccezione se qualcosa va storto

public class ShaderHelper {

public static int createProgram(String vertexSrc, String fragmentSrc) {
GL20 gl = Gdx.gl20;

int vertexShader = compileShader(GL20.GL_VERTEX_SHADER, vertexSrc);
int fragmentShader = compileShader(GL20.GL_FRAGMENT_SHADER, fragmentSrc);

int program = gl.glCreateProgram();
if (program == 0) {
throw new RuntimeException("Impossibile creare il programma shader");
}

gl.glAttachShader(program, vertexShader);
gl.glAttachShader(program, fragmentShader);

gl.glLinkProgram(program);

int[] linkStatus = new int[1];
gl.glGetProgramiv(program, GL20.GL_LINK_STATUS, linkStatus, 0);
if (linkStatus[0] == 0) {
String log = gl.glGetProgramInfoLog(program);
Gdx.app.error("ShaderHelper", "Errore di link del programma: " + log);
gl.glDeleteProgram(program);
throw new RuntimeException("Errore di link del programma shader");
}

// gli shader sono stati linkati nel programma, possiamo cancellarli
gl.glDeleteShader(vertexShader);
gl.glDeleteShader(fragmentShader);

return program;
}

private static int compileShader(int type, String source) {
GL20 gl = Gdx.gl20;

int shader = gl.glCreateShader(type);
if (shader == 0) {
throw new RuntimeException("Impossibile creare lo shader di tipo " + type);
}

gl.glShaderSource(shader, source);
gl.glCompileShader(shader);

int[] compiled = new int[1];
gl.glGetShaderiv(shader, GL20.GL_COMPILE_STATUS, compiled, 0);
if (compiled[0] == 0) {
String log = gl.glGetShaderInfoLog(shader);
Gdx.app.error("ShaderHelper", "Errore di compilazione shader: " + log);
gl.glDeleteShader(shader);
throw new RuntimeException("Errore di compilazione shader");
}

return shader;
}
}

Questa classe è indipendente dal resto del codice di gioco e può essere riutilizzata in tutti i tuoi progetti libGDX/OpenGL ES.

4. Input touch con Gdx.input

Per giochi mobile è essenziale gestire il tocco. libGDX espone un’API semplice:

  • Gdx.input.isTouched(): indica se almeno un dito è sullo schermo
  • Gdx.input.getX(), Gdx.input.getY(): coordinate del primo tocco (in pixel, origine in alto a sinistra)

Dato che la nostra proiezione ortografica ha origine in basso a sinistra, dobbiamo convertire la coordinata Y.

private void handleInput() {
if (Gdx.input.isTouched()) {
int screenX = Gdx.input.getX();
int screenY = Gdx.input.getY();

int width = Gdx.graphics.getWidth();
int height = Gdx.graphics.getHeight();

// converte da sistema con origine in alto a sinistra
// a origine in basso a sinistra (come la nostra matrice ortografica)
float worldX = screenX;
float worldY = height - screenY;

// sposta il quadrato verso il tocco (centro sul tocco)
squareX = worldX - 50f; // metà larghezza rettangolo
squareY = worldY - 25f; // metà altezza rettangolo
}
}

Chiama handleInput() all’inizio di render():

@Override
public void render() {
handleInput();
float delta = Gdx.graphics.getDeltaTime();
// ... resto della logica e del rendering
}

Per input multi-touch o gesture più complesse puoi usare InputProcessor o GestureDetector, ma per uno spostamento diretto di un oggetto queste API base sono più che sufficienti.

5. Overlay UI Android per mostrare gli FPS

Nel primo articolo hai visto come posizionare un pulsante Android sopra il GLSurfaceView. Ora aggiungiamo una TextView che mostra gli FPS in tempo reale, aggiornati dal thread OpenGL tramite runOnUiThread().

Layout XML

Nel layout XML dell’Activity (ad esempio activity_main.xml) aggiungi una TextView sopra il contenitore del GLSurfaceView:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<FrameLayout
android:id="@+id/gl_container"
android:layout_width="match_parent"<br android:layout_height="match_parent" />

<TextView
android:id="@+id/fpsTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|left"
android:layout_margin="8dp"
android:textColor="@android:color/white"
android:background="#55000000"
android:text="FPS: 0" />

</FrameLayout>

AndroidLauncher: riferimento alla TextView

Nell’AndroidLauncher recupera un riferimento alla TextView e passalo al gioco (magari tramite un’interfaccia o direttamente, se vuoi restare semplice):

public class AndroidLauncher extends AndroidApplication {
private TextView fpsTextView;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

FrameLayout glContainer = findViewById(R.id.gl_container);
fpsTextView = findViewById(R.id.fpsTextView);

AndroidApplicationConfiguration config = new AndroidApplicationConfiguration();
View gameView = initializeForView(new ShapeRendererGame(new FpsUpdater() {
@Override
public void updateFps(final int fps) {
runOnUiThread(new Runnable() {
@Override
public void run() {
fpsTextView.setText("FPS: " + fps);
}
});
}
}), config);

glContainer.addView(gameView);
}

public interface FpsUpdater {
void updateFps(int fps);
}
}

Aggiornare gli FPS dal thread OpenGL

Nella classe di gioco ricevi l’FpsUpdater nel costruttore e chiamalo una volta al secondo per evitare overhead inutile.

public class ShapeRendererGame extends ApplicationAdapter {
private final AndroidLauncher.FpsUpdater fpsUpdater;
private float fpsTimer = 0f;

public ShapeRendererGame(AndroidLauncher.FpsUpdater fpsUpdater) {
this.fpsUpdater = fpsUpdater;
}

@Override
public void render() {
float delta = Gdx.graphics.getDeltaTime();
handleInput();

// ... logica e rendering come visto prima ...

updateFpsOverlay(delta);
}

private void updateFpsOverlay(float delta) {
fpsTimer += delta;
if (fpsTimer >= 1f) {
fpsTimer = 0f;
int fps = Gdx.graphics.getFramesPerSecond();
if (fpsUpdater != null) {
fpsUpdater.updateFps(fps);
}
}
}
}

In questo modo il thread OpenGL (libGDX) comunica in modo sicuro con la UI Android, usando runOnUiThread() dall’Activity, come visto nel primo articolo con Gdx.app.postRunnable().

6. Performance del game loop libGDX su Android

Con OpenGL ES è molto facile degradare le performance con poche scelte sbagliate. Ecco alcuni punti chiave per un game loop efficiente.

Quando usare glClear() nel game loop libGDX Android

Chiama glClear() una volta per frame, all’inizio della fase di rendering:

gl.glClearColor(0f, 0f, 0f, 1f);
gl.glClear(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT);

Se non usi il depth buffer per un gioco 2D, puoi limitarti a GL_COLOR_BUFFER_BIT, come negli esempi di questo articolo.

Evitare allocazioni nel game loop libGDX Android

Evitare allocazioni nel game loop libGDX Android

Da evitare:

@Override
public void render() {
// SBAGLIATO: creare oggetti ogni frame
Vector2 pos = new Vector2();
pos.x = ...;
pos.y = ...;
}

Meglio:

public class MyGame extends ApplicationAdapter {
private final Vector2 tmpPos = new Vector2(); // riutilizzato

@Override
public void render() {
tmpPos.set(...);
// usa tmpPos senza creare nuovi oggetti
}
}

Pre-allocare buffer e array nel game loop libGDX Android

Pre-allocare buffer e array in libGDX Android

Nell’esempio di questo articolo creiamo i buffer dei vertici di rettangolo e cerchio in createRectangle() e createCircle(), e non li tocchiamo più durante il loop.

Minimizzare le chiamate OpenGL

Ogni chiamata a glXXX ha un costo. Cerca di:

  • evitare di cambiare continuamente programma shader (glUseProgram)
  • batchare oggetti con lo stesso shader e stato OpenGL
  • usare VAO/VBO per ridurre la configurazione degli attributi

Se inizia a comparire molto codice di rendering di forme 2D, valuta di passare a SpriteBatch per le texture e a strumenti come ShapeRenderer quando non hai bisogno di full control su OpenGL.

Conclusioni: libGDX game loop Android e prossimi passi

In questo secondo articolo hai visto come funziona il game loop di libGDX, come usare correttamente il delta time, come disegnare forme geometriche semplici con OpenGL ES (rettangolo e cerchio), come gestire il tocco per spostare un oggetto, come creare un overlay UI Android per mostrare gli FPS e alcune regole pratiche per mantenere alte le performance.

Nel prossimo passo puoi iniziare a strutturare un vero gioco 2D (ad esempio un Pong o un Arkanoid) partendo da queste basi. Se vuoi un percorso guidato, dai un’occhiata alle guide complete: Pong in Java con OpenGL ES, Arkanoid in Java con OpenGL ES e alla guida generale su come creare un gioco Android in Java.

Commenti

Rispondi

Scopri di più da App Tutorial

Abbonati ora per continuare a leggere e avere accesso all'archivio completo.

Continua a leggere