Gravidade de Verdade — Física com Box2D

Gravidade de Verdade — Física com Box2D

Nesta aula você aprende a usar o Box2D pelo libGDX: mundo, corpos, formas, atrito, restituição, juntas e sensores. Cinco experimentos numéricos mostram por que ele pensa em metros, por que tem um limite de velocidade, quando um objeto rápido atravessa outro e por que corpos em repouso "dormem".
Java

• • 26 min de leitura

Nesta aula você aprende a usar o Box2D pelo libGDX: mundo, corpos, formas, atrito, restituição, juntas e sensores. Cinco experimentos numéricos mostram por que ele pensa em metros, por que tem um limite de velocidade, quando um objeto rápido atravessa outro e por que corpos em repouso "dormem". Depois, uma ponte de tábuas sob uma chuva de caixas mostra o que acontece quando se destrói um corpo no momento errado.

O Asteroids funciona com uma física de brinquedo: as coisas se movem em linha reta, e uma colisão simplesmente remove os envolvidos. Assim que um jogo precisa que as coisas se empilhem, rolem, quiquem, balancem ou desabem, a física de brinquedo não serve mais. Resolver o contato entre dezenas de corpos ao mesmo tempo, com atrito, sem que uma pilha trema ou afunde, é um problema difícil. Felizmente, ele já foi resolvido.

O Box2D

O Box2D foi criado por Erin Catto, que o apresentou na conferência de desenvolvedores de jogos (GDC) de 2006 como material de uma palestra sobre simulação de corpos rígidos. Escrito em C++ e publicado como código aberto, ele se tornou o motor de física 2D mais usado do mundo. Estão entre os títulos que o usam:

  • Angry Birds, com seus porcos, blocos e estilingues;
  • Limbo;
  • Crayon Physics Deluxe;
  • milhares de jogos independentes;
  • dezenas de outros motores, que o embutem.

Em 2024, Catto lançou o Box2D 3, reescrito em C, mais rápido e com outra API.

O libGDX traz o Box2D na extensão gdx-box2d, que embrulha o código C++ original via JNI. A versão embutida é a 2.3.1, a da API clássica, a mesma que aparece na maioria dos tutoriais e livros. Os nomes das classes e dos métodos seguem o C++ quase um a um, e por isso a documentação oficial do Box2D serve diretamente. Para usá-la:

// core/build.gradle
api "com.badlogicgames.gdx:gdx-box2d:$gdxVersion"
// lwjgl3/build.gradle
implementation "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-desktop"

Os conceitos

O vocabulário do Box2D é pequeno e preciso:

  • World (mundo): o espaço da simulação, com uma gravidade. É quem avança o tempo, com world.step(...).
  • Body (corpo): um objeto rígido com posição, ângulo e velocidade. Há três tipos:

    • estático (StaticBody): nunca se move, massa infinita. Chão, paredes, o cais.
    • dinâmico (DynamicBody): sofre forças, gravidade e colisões. Caixas, bolas, personagens.
    • cinemático (KinematicBody): move-se com a velocidade que você define, ignora forças e empurra os dinâmicos. Plataformas móveis, elevadores.
  • Fixture (acessório): prende uma forma (CircleShape, PolygonShape, EdgeShape, ChainShape) a um corpo, com as propriedades de material:

    • densidade (a massa vem de densidade × área);
    • atrito, de 0 (gelo) a 1 ou mais (borracha);
    • restituição, de 0 (massa de modelar) a 1 (quique perfeito).

    Um corpo pode ter várias fixtures: um carro é um retângulo e dois círculos.

  • Sensor: uma fixture marcada com isSensor, que detecta contatos mas não empurra ninguém. Serve para zonas de chegada, água, gatilhos e áreas de dano.
  • Joint (junta): prende dois corpos. As mais usadas:

    • de revolução, uma dobradiça;
    • de distância, uma haste;
    • prismática, um trilho;
    • de roda.
  • ContactListener: o ouvinte avisado quando duas fixtures começam e terminam de se tocar.

Cinco experimentos

Antes de fazer um jogo, vale conhecer o comportamento do motor, com números. O programa abaixo roda o Box2D real, sem janela:

import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.physics.box2d.*;
import com.badlogic.gdx.utils.GdxNativesLoader;

import java.util.Locale;

/** Cinco experimentos com o Box2D de verdade, sem janela: cada um mostra uma regra de uso. */
public class LaboratorioBox2D {
    static final float PASSO = 1 / 60f;

    public static void main(String[] args) {
        GdxNativesLoader.load();
        Box2D.init();
        Locale.setDefault(Locale.of("pt", "BR"));
        escala();
        limiteDeVelocidade();
        tunelamento(false);
        tunelamento(true);
        quique();
        sono();
    }

    // ---------- utilidades ----------
    static World mundo() { return new World(new Vector2(0, -9.8f), true); }

    static Body caixa(World w, BodyDef.BodyType tipo, float x, float y, float meiaLarg, float meiaAlt, float densidade) {
        var bd = new BodyDef();
        bd.type = tipo;
        bd.position.set(x, y);
        Body b = w.createBody(bd);
        var forma = new PolygonShape();
        forma.setAsBox(meiaLarg, meiaAlt);
        b.createFixture(forma, densidade);
        forma.dispose();                               // a forma foi COPIADA para a fixture: libere a original
        return b;
    }

    static Body bola(World w, float x, float y, float raio, float restituicao) {
        var bd = new BodyDef();
        bd.type = BodyDef.BodyType.DynamicBody;
        bd.position.set(x, y);
        Body b = w.createBody(bd);
        var forma = new CircleShape();
        forma.setRadius(raio);
        var fd = new FixtureDef();
        fd.shape = forma;
        fd.density = 1f;
        fd.restitution = restituicao;
        b.createFixture(fd);
        forma.dispose();
        return b;
    }

    // ---------- 1. unidades: metros, não pixels ----------
    static void escala() {
        System.out.println("--- 1. Quanto tempo cada caixa leva para cair a própria altura?");
        for (float lado : new float[]{1f, 100f}) {
            World w = mundo();
            Body b = caixa(w, BodyDef.BodyType.DynamicBody, 0, 1000, lado / 2, lado / 2, 1);
            float y0 = b.getPosition().y;
            int passos = 0;
            while (y0 - b.getPosition().y < lado && passos < 10_000) { w.step(PASSO, 8, 3); passos++; }
            System.out.printf("caixa de %5.0f m: %.2f s%n", lado, passos * PASSO);
            w.dispose();
        }
    }

    // ---------- 2. o limite de deslocamento por passo ----------
    static void limiteDeVelocidade() {
        System.out.println("--- 2. Pedi 300 m/s; quanto o corpo andou?");
        World w = new World(new Vector2(0, 0), true);
        Body b = caixa(w, BodyDef.BodyType.DynamicBody, 0, 0, 0.5f, 0.5f, 1);
        b.setLinearVelocity(300, 0);
        w.step(PASSO, 8, 3);
        System.out.printf("pedido: %.1f m no passo; andou: %.2f m (%.0f m/s)%n",
                300 * PASSO, b.getPosition().x, b.getPosition().x / PASSO);
        w.dispose();
    }

    // ---------- 3. tunelamento entre corpos dinâmicos ----------
    static void tunelamento(boolean bala) {
        World w = new World(new Vector2(0, 0), true);
        Body tabua = caixa(w, BodyDef.BodyType.DynamicBody, 5, 0, 0.05f, 1f, 1);   // 10 cm de espessura
        Body projetil = bola(w, 0, 0, 0.05f, 0);
        projetil.setBullet(bala);
        projetil.setLinearVelocity(100, 0);                 // 1,67 m por passo: mais que a espessura da tábua
        for (int i = 0; i < 12; i++) w.step(PASSO, 8, 3);
        System.out.printf("--- 3. bullet=%s: projétil em x=%.2f, tábua em x=%.2f → %s%n", bala,
                projetil.getPosition().x, tabua.getPosition().x,
                projetil.getPosition().x > tabua.getPosition().x ? "ATRAVESSOU" : "bateu");
        w.dispose();
    }

    // ---------- 4. restituição ----------
    static void quique() {
        System.out.println("--- 4. Bola solta de 2 m: altura do primeiro quique");
        for (float r : new float[]{0f, 0.5f, 0.9f}) {
            World w = mundo();
            caixa(w, BodyDef.BodyType.StaticBody, 0, -0.5f, 10, 0.5f, 0);          // chão com topo em y = 0
            Body b = bola(w, 0, 2.25f, 0.25f, r);
            float maxDepois = 0;
            boolean bateu = false;
            for (int i = 0; i < 240; i++) {
                w.step(PASSO, 8, 3);
                if (b.getLinearVelocity().y > 0) bateu = true;
                if (bateu) maxDepois = Math.max(maxDepois, b.getPosition().y - 0.25f);
            }
            System.out.printf("restituição %.1f → sobe %.2f m%n", r, maxDepois);
            w.dispose();
        }
    }

    // ---------- 5. corpos em repouso dormem ----------
    static void sono() {
        World w = mundo();
        caixa(w, BodyDef.BodyType.StaticBody, 0, -0.5f, 10, 0.5f, 0);
        Body[] pilha = new Body[8];
        for (int i = 0; i < pilha.length; i++) pilha[i] = caixa(w, BodyDef.BodyType.DynamicBody, 0, 0.5f + i * 1.01f, 0.5f, 0.5f, 1);
        int passos = 0;
        while (passos < 1200 && java.util.Arrays.stream(pilha).anyMatch(Body::isAwake)) { w.step(PASSO, 8, 3); passos++; }
        System.out.printf("--- 5. pilha de 8 caixas: todas dormindo após %.2f s; topo em y=%.3f%n",
                passos * PASSO, pilha[7].getPosition().y);
        w.dispose();
    }
}
--- 1. Quanto tempo cada caixa leva para cair a própria altura?
caixa de     1 m: 0,45 s
caixa de   100 m: 4,52 s
--- 2. Pedi 300 m/s; quanto o corpo andou?
pedido: 5,0 m no passo; andou: 2,00 m (120 m/s)
--- 3. bullet=false: projétil em x=20,00, tábua em x=5,00 → ATRAVESSOU
--- 3. bullet=true: projétil em x=5,47, tábua em x=5,57 → bateu
--- 4. Bola solta de 2 m: altura do primeiro quique
restituição 0,0 → sobe 0,00 m
restituição 0,5 → sobe 0,48 m
restituição 0,9 → sobe 1,56 m
--- 5. pilha de 8 caixas: todas dormindo após 0,85 s; topo em y=7,578

Cada linha é uma regra de uso.

1. Metros, não pixels. O Box2D trabalha em metros, quilogramas e segundos, e foi afinado para objetos entre 10 cm e 10 m. O erro mais comum de quem começa é usar pixels como unidade: um sprite de 100 pixels vira uma caixa de 100 metros, do tamanho de um prédio. A física continua correta, e é justamente esse o problema. Uma caixa de 1 m leva 0,45 s para cair a própria altura; uma "caixa" de 100 m leva 4,52 s. Na tela, com os dois do mesmo tamanho, a grande parece cair em câmera lenta, e o jogo fica "flutuante" sem que se saiba por quê. É o mesmo motivo pelo qual o nosso mundo tem 16 × 9 metros desde "O Primeiro Sprite — Janela, Desenho e Input com libGDX": com os viewports do libGDX, a física e a tela já falam a mesma unidade.

2. O limite de velocidade. Pedi 300 m/s, e o corpo andou 2 m no passo, ou seja, 120 m/s. O Box2D limita o deslocamento de um corpo a 2 metros por passo: a 60 passos por segundo, nada anda mais de 120 m/s. O limite existe para manter a simulação estável. Com metros de verdade ele nunca atrapalha, porque um carro de corrida anda a 90 m/s. Com a escala errada do item 1 é um desastre: 300 pixels por segundo é uma velocidade comum, que, tratada como 300 m/s, bate no teto e deixa todo movimento rápido estranhamente travado.

3. Tunelamento, de novo. É o mesmo problema da aula sobre colisões. Entre um corpo dinâmico e um estático, o Box2D já calcula o instante exato do impacto (a colisão contínua) e não deixa atravessar. Entre dois dinâmicos, isso não é feito por padrão, porque custa caro. O projétil a 100 m/s avança 1,67 m por passo, muito mais que os 10 cm da tábua, e passa direto. Marcando-o como bullet, o Box2D faz a colisão contínua também contra dinâmicos, e o projétil bate. Use setBullet(true) só no que é realmente rápido e pequeno.

4. Restituição. Com restituição 0,5, a bola que caiu de 2 m sobe 0,48 m; a física de livro diz 0,5² × 2 = 0,5 m. Com 0,9, sobe 1,56 m, contra 1,62 m teóricos. A diferença vem da integração por passos, que você conhece desde a aula sobre game loop. Aliás, o Box2D integra do mesmo jeito que o PassoFixo daquela aula, com Euler semi-implícito: um corpo solto de 10 m, depois de 60 passos de 1/60 s, está em y = 5,018333 com vy = -9,799998, os mesmos números (até a sexta casa) que o cálculo semi-implícito feito à mão.

5. Corpos dormem. Depois de 0,85 s, a pilha inteira "dorme": um corpo que fica meio segundo praticamente parado (o b2_timeToSleep) adormece, e o Box2D para de simulá-lo até que algo o toque. É isso que permite cenas com milhares de corpos parados custando quase nada. E o topo da pilha está em 7,578, e não em 7,5 como a aritmética diria. Cada polígono do Box2D tem uma "pele" de 1 cm (o b2_polygonRadius; o getRadius() da forma devolve 0,01), e as caixas se apoiam pela pele, e não pela borda desenhada. Medidas uma a uma, as oito folgas (sete entre caixas e uma com o chão) ficam entre 0,7 e 1,3 cm, porque o solucionador deixa as peles se sobreporem um pouco, e somam os 7,8 cm de diferença. Se o seu sprite parecer flutuar um fio acima do chão, a pele é o motivo, e desenhar o sprite um pouco maior que a forma de colisão resolve.

Um detalhe das utilidades vale para o resto da aula: o forma.dispose() logo depois de criar a fixture. A forma do Box2D vive em memória nativa, e a fixture guarda uma cópia dela. A original, se não for liberada, vaza, como as texturas da Travessia.

Uma ponte sob uma chuva de caixas

A cena abaixo junta tudo: dois cais estáticos, uma ponte de doze tábuas presas por juntas de revolução, caixas que caem do céu e o mar, que é um sensor. As caixas que caem na água são removidas. A tecla C corta a dobradiça do meio da ponte.

package br.com.exemplo.cais;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.InputAdapter;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.PixmapIO;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.physics.box2d.*;
import com.badlogic.gdx.physics.box2d.joints.RevoluteJointDef;
import com.badlogic.gdx.physics.box2d.Joint;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.ScreenUtils;
import com.badlogic.gdx.utils.viewport.FitViewport;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

/**
 * Uma ponte de tábuas presas por juntas entre dois cais; caixas caem do céu.
 * As que caem no mar tocam um SENSOR e são removidas depois do passo.
 */
public class CaisDeCargas extends ApplicationAdapter {
    static final float LARGURA = 24f, ALTURA = 13.5f;     // metros
    static final float PASSO = 1f / 60f;
    static final int VEL_ITER = 8, POS_ITER = 3;

    private World mundo;
    private Box2DDebugRenderer depurador;
    private FitViewport vista;
    private ScreenViewport vistaHud;
    private SpriteBatch lote;
    private BitmapFont fonte;
    private float acumulado;
    private int noMar, fotos;
    private final Array<Body> paraRemover = new Array<>();
    private final Array<Joint> juntas = new Array<>();
    private final Vector2 toque = new Vector2();

    @Override
    public void create() {
        Box2D.init();
        mundo = new World(new Vector2(0, -9.8f), true);
        depurador = new Box2DDebugRenderer();
        vista = new FitViewport(LARGURA, ALTURA, new OrthographicCamera());
        vistaHud = new ScreenViewport();
        lote = new SpriteBatch();
        fonte = new BitmapFont();

        Body caisEsquerdo = estatico(3f, 2f, 3f, 2f);          // cais: caixas estáticas de 6 x 4 m
        Body caisDireito = estatico(LARGURA - 3f, 2f, 3f, 2f);
        criarPonte(caisEsquerdo, caisDireito);
        criarMar();

        mundo.setContactListener(new ContactListener() {
            @Override
            public void beginContact(Contact contato) {
                // NÃO destrua corpos aqui: o mundo está "travado" no meio do passo. Só anote.
                Fixture a = contato.getFixtureA(), b = contato.getFixtureB();
                if ("mar".equals(a.getUserData()) && !paraRemover.contains(b.getBody(), true)) paraRemover.add(b.getBody());
                if ("mar".equals(b.getUserData()) && !paraRemover.contains(a.getBody(), true)) paraRemover.add(a.getBody());
            }
            @Override public void endContact(Contact contato) {}
            @Override public void preSolve(Contact contato, Manifold manifold) {}
            @Override public void postSolve(Contact contato, ContactImpulse impulso) {}
        });

        Gdx.input.setInputProcessor(new InputAdapter() {
            @Override
            public boolean touchDown(int x, int y, int ponteiro, int botao) {
                vista.unproject(toque.set(x, y));
                soltarCaixa(toque.x, ALTURA - 1f);
                return true;
            }

            @Override
            public boolean keyDown(int tecla) {
                if (tecla == Keys.SPACE) soltarCaixa(MathUtils.random(4f, LARGURA - 4f), ALTURA - 1f);
                if (tecla == Keys.C && juntas.size > 0) {          // corta a dobradiça do meio da ponte
                    mundo.destroyJoint(juntas.removeIndex(juntas.size / 2));
                }
                if (tecla == Keys.F12) fotografar();
                if (tecla == Keys.ESCAPE) Gdx.app.exit();
                return true;
            }
        });
    }

    private Body estatico(float x, float y, float meiaLarg, float meiaAlt) {
        var bd = new BodyDef();
        bd.position.set(x, y);                                   // tipo padrão: StaticBody
        Body b = mundo.createBody(bd);
        var forma = new PolygonShape();
        forma.setAsBox(meiaLarg, meiaAlt);
        b.createFixture(forma, 0);
        forma.dispose();
        return b;
    }

    /** 12 tábuas de 1 m, cada uma presa à vizinha por uma junta de revolução (uma dobradiça). */
    private void criarPonte(Body inicio, Body fim) {
        int tabuas = 12;
        float vao = (LARGURA - 12f) / tabuas, y = 4f;
        var forma = new PolygonShape();
        forma.setAsBox(vao / 2, 0.1f);
        var fd = new FixtureDef();
        fd.shape = forma;
        fd.density = 2f;
        fd.friction = 0.6f;
        Body anterior = inicio;
        var junta = new RevoluteJointDef();
        for (int i = 0; i < tabuas; i++) {
            var bd = new BodyDef();
            bd.type = BodyDef.BodyType.DynamicBody;
            bd.position.set(6f + vao / 2 + i * vao, y);
            Body tabua = mundo.createBody(bd);
            tabua.createFixture(fd);
            junta.initialize(anterior, tabua, new Vector2(6f + i * vao, y));   // o ponto da dobradiça, no mundo
            juntas.add(mundo.createJoint(junta));
            anterior = tabua;
        }
        junta.initialize(anterior, fim, new Vector2(LARGURA - 6f, y));
        juntas.add(mundo.createJoint(junta));
        forma.dispose();
    }

    /** O mar: uma faixa que não empurra ninguém (sensor), só avisa quem entrou nela. */
    private void criarMar() {
        var bd = new BodyDef();
        bd.position.set(LARGURA / 2, 0.5f);
        Body mar = mundo.createBody(bd);
        var forma = new PolygonShape();
        forma.setAsBox(LARGURA / 2 - 6f, 0.5f);
        var fd = new FixtureDef();
        fd.shape = forma;
        fd.isSensor = true;
        mar.createFixture(fd).setUserData("mar");
        forma.dispose();
    }

    private void soltarCaixa(float x, float y) {
        var bd = new BodyDef();
        bd.type = BodyDef.BodyType.DynamicBody;
        bd.position.set(x, y);
        bd.angle = MathUtils.random(-0.4f, 0.4f);
        Body b = mundo.createBody(bd);
        var forma = new PolygonShape();
        float lado = MathUtils.random(0.4f, 0.8f);
        forma.setAsBox(lado, lado);
        var fd = new FixtureDef();
        fd.shape = forma;
        fd.density = 1f;
        fd.friction = 0.5f;
        fd.restitution = 0.1f;
        b.createFixture(fd);
        forma.dispose();
    }

    @Override
    public void resize(int largura, int altura) {
        vista.update(largura, altura, true);
        vistaHud.update(largura, altura, true);
    }

    @Override
    public void render() {
        acumulado += Math.min(Gdx.graphics.getDeltaTime(), 0.25f);
        while (acumulado >= PASSO) {
            mundo.step(PASSO, VEL_ITER, POS_ITER);            // SEMPRE o mesmo passo
            acumulado -= PASSO;
            for (Body b : paraRemover) {                          // fora do passo: agora pode
                // destruir um corpo destrói as juntas dele: tire-as da nossa lista ANTES,
                // ou guardaremos ponteiros para memória nativa já liberada
                for (JointEdge aresta : b.getJointList()) juntas.removeValue(aresta.joint, true);
                mundo.destroyBody(b);
                noMar++;
            }
            paraRemover.clear();
        }

        ScreenUtils.clear(Color.valueOf("0b1d33"));
        vista.apply();
        depurador.render(mundo, vista.getCamera().combined);

        vistaHud.apply();
        lote.setProjectionMatrix(vistaHud.getCamera().combined);
        lote.begin();
        fonte.draw(lote, "ESPAÇO ou clique: soltar caixa   C: cortar a ponte   |   corpos: %d   no mar: %d   dormindo: %d"
                .formatted(mundo.getBodyCount(), noMar, dormindo()), 10, vistaHud.getScreenHeight() - 10);
        lote.end();
    }

    private int dormindo() {
        var corpos = new Array<Body>();
        mundo.getBodies(corpos);
        int n = 0;
        for (Body b : corpos) if (b.getType() == BodyDef.BodyType.DynamicBody && !b.isAwake()) n++;
        return n;
    }

    private void fotografar() {
        Pixmap tela = Pixmap.createFromFrameBuffer(0, 0, Gdx.graphics.getBackBufferWidth(), Gdx.graphics.getBackBufferHeight());
        PixmapIO.writePNG(Gdx.files.local("cais-" + (++fotos) + ".png"), tela, -1, true);
        tela.dispose();
        Gdx.app.log("Cais", "foto %d: %d corpos, %d no mar, %d dormindo".formatted(fotos, mundo.getBodyCount(), noMar, dormindo()));
    }

    @Override
    public void dispose() {
        mundo.dispose();                                     // memória NATIVA do Box2D
        depurador.dispose();
        lote.dispose();
        fonte.dispose();
    }
}

Os pontos de atenção, um por um.

A ponte. Cada tábua é um corpo dinâmico. Cada RevoluteJointDef.initialize(a, b, ponto) prende dois corpos por um ponto do mundo que vira o eixo da dobradiça: o cais à primeira tábua, cada tábua à seguinte, a última ao outro cais. Não há código de "ponte": ela verga, balança e sustenta peso porque o solucionador de restrições do Box2D mantém as treze dobradiças unidas a cada passo.

O passo e as iterações. mundo.step(PASSO, 8, 3) avança 1/60 s com oito iterações de velocidade e três de posição. Mais iterações dão pilhas e juntas mais firmes a um custo maior, e 8/3 é o valor recomendado pelo próprio Box2D. O passo é sempre o mesmo, com o acumulador de sempre. A documentação do Box2D é enfática: passos variáveis deixam a simulação instável.

O depurador. O Box2DDebugRenderer desenha as formas de colisão (cores diferentes para corpos acordados, dormindo e estáticos) e as juntas. Num jogo, você desenha sprites nas posições dos corpos (body.getPosition() e body.getAngle()) e liga o depurador só durante o desenvolvimento.

O sensor e a regra do mundo travado. Quando uma caixa toca o mar, o beginContact é chamado no meio do step, enquanto o Box2D percorre suas estruturas internas. Destruir um corpo ali é proibido: o mundo está travado (world.isLocked()), e a destruição corromperia o que ele está percorrendo. O padrão é anotar o corpo numa lista e destruí-lo depois do step. É a mesma regra do Iterator do Asteroids (não modificar uma coleção enquanto se itera sobre ela), só que agora dentro de uma biblioteca nativa, onde a consequência não é uma exceção amigável.

O dia em que a JVM caiu

A cena foi rodada com o libGDX 1.14.2 no JDK 27, numa janela de verdade no Linux. Para não depender de um jogador, um lançador de teste chamou o próprio InputAdapter do jogo, como o backend faria: dezesseis vezes ESPAÇO, uma a cada 1/3 de segundo, e, dez segundos depois do início, F12, C e F12 de novo; quatro segundos depois, mais uma foto:

[Cais] foto 1: 31 corpos, 0 no mar, 4 dormindo       ← antes do corte
[Cais] foto 2: 31 corpos, 0 no mar, 3 dormindo       ← o instante do corte
[Cais] foto 3: 11 corpos, 20 no mar, 8 dormindo      ← quatro segundos depois

As capturas mostram a história:

  • Antes do corte: a ponte vergada sob as caixas, e as quatro que caíram sobre os cais paradas e marcadas como dormindo.
  • No instante do corte: a ponte já aberta no meio, com as duas metades começando a ceder.
  • Quatro segundos depois: duas tábuas penduradas em cada cais, e as caixas e tábuas que tocaram o mar removidas pelo sensor. São 20: 12 caixas e 8 tábuas. Sobraram os 2 cais, o mar, as 4 caixas nos cais e as 4 tábuas penduradas: 11 corpos.

As caixas caem em posições sorteadas, então os números mudam de uma execução para outra; em três sementes, a foto final teve 10, 11 e 12 corpos, e a conta corpos + no mar fechou sempre em 31. Agora, a mesma cena sem as três linhas do JointEdge no render(), com o mesmo roteiro e mais um C depois de as tábuas caírem no mar:

#
# A fatal error has been detected by the Java Runtime Environment:
#
#  SIGSEGV (0xb) at pc=0x0000000000000000, pid=19884, tid=19885
#
...
# Problematic frame:
# C  [libgdx-box2d64.so+0x29182]  b2Joint::Destroy(b2Joint*, b2BlockAllocator*)+0x12

A JVM inteira caiu, nas três sementes, já no primeiro C depois da queda. Não houve exceção, catch, nem rastro em Java: o processo morreu com uma falha de segmentação dentro do código C++ do Box2D e deixou um arquivo hs_err_pid19884.log. A causa: quando uma tábua tocou o mar e foi destruída, o Box2D destruiu junto as juntas dela, liberando a memória nativa. Mas a nossa lista juntas continuava guardando os objetos Java que apontavam para aquela memória. O C seguinte chamou destroyJoint num ponteiro já liberado, o erro clássico do use-after-free que "Ponte para o Nativo — a Foreign Function & Memory API" mostrou como a FFM tenta evitar com arenas e escopos.

A correção são as três linhas antes do destroyBody: percorrer as juntas do corpo (b.getJointList()) e tirá-las da nossa lista antes de destruí-lo. Com elas, o mesmo roteiro, com cinco cortes seguidos depois da queda, terminou limpo:

[Cais] foto 4: 7 corpos, 24 no mar, 4 dormindo

É a lição mais importante desta aula, e vale para qualquer biblioteca nativa usada pelo Java: fora do heap, o coletor de lixo não protege você. O objeto Java Joint continuava vivo e acessível, com cara de válido, enquanto a coisa que ele representava já não existia. Toda referência a um recurso nativo precisa acompanhar o ciclo de vida desse recurso, e o dispose() final do World libera de uma vez corpos, juntas e formas que ainda restarem.

Física e jogo, juntos

Nesta aula, a física era o jogo. Na maioria dos jogos ela é uma ferramenta a serviço das regras, e vale saber onde as duas se encontram:

  • Personagens controláveis não costumam ser corpos dinâmicos puros. Um herói que escorrega em rampas, quica em quinas e gira ao bater é realista e irritante. O comum é um corpo com a rotação travada (fixedRotation), atrito zero nas laterais e velocidade horizontal controlada diretamente pelas regras, deixando o Box2D cuidar só das colisões e da gravidade.
  • Os sensores são o elo com as regras. Moedas, portas, zonas de dano e linhas de chegada são sensores, cujos contatos viram eventos para a lógica do jogo, sempre processados depois do step.
  • Filtros de colisão. Tiros que não acertam quem os disparou, fantasmas que atravessam paredes: cada fixture tem categorias e máscaras (Filter) que dizem com quem ela colide.

Com física e colisões dominadas, falta dar ao jogo um lugar: fases com chão, paredes, plataformas e buracos, desenhadas num editor e carregadas pelo jogo, em vez de caixas posicionadas uma a uma no código. E, num jogo de plataforma, o passo fixo volta ao centro: é ele que decide se o pulo alcança a próxima plataforma.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega fez um jogo com sprites de 64 pixels e criou os corpos com setAsBox(32, 32), usando pixels como metros. Descreva o que o jogador vai sentir, com base nos experimentos 1 e 2, e como corrigir sem redesenhar os sprites.

Ver resposta

✓ Resposta: Cada caixa terá 64 metros. Quedas vão parecer em câmera lenta, porque objetos gigantes levam segundos para cair a própria altura (4,52 s para 100 m, no experimento 1), e qualquer movimento acima de 120 "pixels" por segundo vai bater no limite de 2 m por passo (experimento 2). O resultado é um jogo lento, flutuante e com velocidades travadas. A correção é escolher uma escala, por exemplo 32 pixels por metro, criar os corpos em metros (setAsBox(1, 1) para o sprite de 64 px) e usar um viewport em metros, como o FitViewport(20, 11.25f). Os sprites continuam os mesmos, desenhados com lote.draw(tex, x, y, 2, 2), em metros. A escala fica num lugar só, a câmera, e nunca espalhada pelo código em divisões por 32.

Exercício 2

No beginContact, um colega chamou mundo.destroyBody(b) diretamente, "para ser mais simples". Não houve exceção: o processo inteiro morreu com a mensagem Assertion `IsLocked() == false' failed, vinda do b2World.cpp. Explique por que essa proteção existe e por que a lista paraRemover é a solução correta.

Ver resposta

✓ Resposta: O beginContact é chamado durante o step, enquanto o Box2D percorre a lista de contatos e as ilhas de corpos que está resolvendo. Destruir um corpo nesse momento removeria elementos das estruturas que estão sendo percorridas, com a mesma natureza da ConcurrentModificationException das coleções Java, mas em código C++, onde o resultado seria memória corrompida ou uma falha. O Box2D marca o mundo como travado (isLocked()) durante o passo, e o DestroyBody do C++ confere isso com uma asserção, que no gdx-box2d está ligada: a camada Java não verifica nada, e o processo é abortado (SIGABRT, código de saída 134), sem exceção para capturar. A proteção é dura, mas melhor que corromper a memória em silêncio. Anotar os corpos e destruí-los depois do step, quando o mundo está destravado, é o padrão recomendado pela documentação. O contains evita anotar o mesmo corpo duas vezes, se ele tocar o sensor com duas fixtures ou em dois contatos.

Exercício 3

A ponte usa juntas de revolução. Transforme-a numa ponte levadiça: a tábua do meio pode ser erguida e abaixada com a tecla L. Que tipo de junta ou de corpo você usaria?

Ver resposta

✓ Resposta: Há dois caminhos. Com juntas, divida a ponte em duas metades, cada uma presa ao seu cais por uma RevoluteJoint com motor (enableMotor = true, maxMotorTorque alto) e limites de ângulo (enableLimit, lowerAngle 0 e upperAngle ≈ 80°). A tecla L inverte o sinal de setMotorSpeed, e a física levanta as metades, derrubando as caixas em cima. Com corpos, a parte móvel pode ser um corpo cinemático, que se move com a velocidade angular que você define e empurra os dinâmicos, sem ser afetado por eles. O motor é mais realista (o peso das caixas pode impedir a subida se o torque for pequeno), e o cinemático é mais previsível para uma mecânica de jogo.

Exercício 4

No experimento 3, o projétil bullet bateu na tábua. Marque também a tábua como bullet e o projétil como não-bullet. O resultado muda? O que isso diz sobre em quem ligar a opção?

Ver resposta

✓ Resposta: Não muda: o projétil bate e para em x = 5,47, com a tábua empurrada até 5,57, os mesmos números do experimento. Para um par de corpos dinâmicos, o Box2D faz a colisão contínua se qualquer um dos dois for bullet. Mesmo assim, a opção deve ser ligada no objeto rápido e pequeno: marcar o alvo só protege contra o que bate nele, enquanto o projétil marcado não atravessa nada, e cada bullet encarece o passo. Estáticos nunca precisam, porque o Box2D já faz colisão contínua de dinâmicos contra eles.

Exercício 5

Explique, com as palavras da aula, por que a falha de segmentação só apareceu quando a tecla C foi apertada depois de as tábuas caírem no mar, e não no primeiro corte.

Ver resposta

✓ Resposta: No primeiro corte, todas as juntas da lista ainda existiam, e destroyJoint recebeu um ponteiro válido. Depois, as tábuas que tocaram o mar foram destruídas pelo sensor, e o Box2D destruiu junto as juntas presas a elas, liberando essa memória nativa. A lista juntas continuou com os objetos Java que apontavam para ela. O próximo C escolheu uma dessas juntas mortas e passou ao C++ um ponteiro para memória liberada, e o acesso derrubou o processo. O erro depende da ordem dos acontecimentos: tudo funciona até que a sequência "destruir corpo, depois usar uma junta dele" ocorra. É por isso que erros de ciclo de vida de recursos nativos escapam de testes simples, e por isso a correção precisa ser estrutural (sincronizar a lista no momento da destruição), e não uma verificação pontual.

Comentários

Mais em Java

O Destino e o Novo Começo — Encerrando a Jornada Completa
O Destino e o Novo Começo — Encerrando a Jornada Completa

Nesta última aula você olha para o caminho inteiro, do primeiro "Olá, mundo"…

Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa
Do Código ao Bytecode — JDK, javac, JVM e o Primeiro Programa

Instalação do JDK 25, a diferença entre JDK, JRE e JVM, o primeiro programa…

Do Geral ao Específico — Herança e a Classe Object
Do Geral ao Específico — Herança e a Classe Object

Herança em Java: extends, a chamada a super e a ordem de construção…