(j)Box2d

Für unser neues Android-Gameprojekt (ist noch unter höchster Geheimhaltungsstufe) brauchte ich eine Physik-Engine. Natürlich trifft man als allererstes auf Box2D bzw. deren Java-Version jBox2D.

Ich griff zu letzterer, nachdem ich irgendwo gelesen hatte, der Performanceunterschied sei nicht allzu groß. Dass das Projekt seit 2014 kein Release mehr gesehen hat (aber durchaus Commits!), fand ich nicht weiter schlimm, es ist stabil, gut getestet, von recht hoher Codequalität und da die Physik selten Features hinzugewinnt, muss auch so eine Library nicht dauernd irgendwas nachziehen.

Ich litt etwas unter einem gewissen Mangel an Beispielcode – die meisten Programmierer verwenden heutzutage ja multiplattformhalber Unity und mühen sich nicht mit Android-Libraries ab. Ich bin da speziell – durch konsequente Nichtunterstützung von iPhones habe ich den Apfelkonzern schon an den Rand des Ruins gebracht.

Bei Levels mit etwas mehr physikalischen Objekten darin hatte die Engine merklich Probleme, die Framerate von mindestens 25fps zu halten, jedenfalls in Momenten mit vielen Kollisionen; teils flogen auch mal Teile durcheinander durch. Nun muss man wissen, dass jBox2Ds Funktion world.step() (die für die Physik zuständig ist) single-threaded ist, aber Phones und Tablets heutzutagen auch schon mit 2, 4 oder 8 Kernen daher kommen. Da wird eine Menge Potenzial nicht genutzt. Zwar gilt für die native C++-Version dasselbe, aber Maschinensprache ist eben per se schneller als Java.

Der große Aha-Effekt kam, als ich mir probeweise ein chinesisches Billig-Tablet beschaffte (XGody, 10 Zoll, knappe 70 Euro bei ebay, aber sogar mit OTG-Kabel in der Schachtel (zur hypercoolen Verwendung solcher Kabel in Kürze mehr an dieser Stelle)). Die Framerate sank bei Action im Bild auf unter 5 fps. Autsch.

Eine schnelle Messung ergab: Die Grafik ist nicht schuld, das komplette Zeichnen inklusive Debug-Hilfslinien etc. dauert (im TextureView) nur 25ms. Aber world.step() brauchte 40ms und mehr.

Also ging ich daran, jBox2D durch box2D zu ersetzen. Nun darf man sich das nicht zu einfach vorstellen, denn die Bibliothek gibt es nur im Quellcode, und übrigens auch auf dem Stand von 2014, wie gesagt: Seither hat sich an der Physik dieses Universums nichts verändert.

Die einzige praktikable Option heißt libgdx: Diese Multiplattform-Gameengine verfügt nämlich über ein Erweiterungsmodul, das die native box2D-Bibliothek enthält, und zwar verpackt in einen dünnen Java-Wrapper. Wie sich herausstellte, ließ sich jBox2D fast 1:1 durch libgdx-box2D ersetzen. Fast.

Ein paar Klassen heißen in box2D anders, z.B. Vector2 statt Vec2. Einige Attribute sind nur mit Gettern und nicht direkt zugreifbar. Und body.userdata lässt sich nicht über BodyDef setzen, nur direkt im Body. Eine Klasse AABB (zur Kollisionsberechnung) gibt es nicht, world.QueryAABB() nimmt direkt die Koordinaten des Rechtecks entgegen. Alles recht schmerzfrei.

Natürlich wollte ich nicht das ganze Projekt auf libgdx umstellen, deshalb konnte ich nicht den libgdx-eigenen Project-Wizard verwenden, der auf Wunsch direkt ein Gradle-Projekt mit Box2D-Unterstützung erzeugt. Und wie man das per Hand hinbekommt, steht nirgendwo. Also war probieren angesagt. Entscheidend sind ein paar Änderungen in der build.gradle. Als da wären:

android {
  ...
  defaultConfig {
    ...
    ndk {
      abiFilters "armeabi-v7a", "x86", "armeabi", "x86_64", "arm64-v8a"
    }
  }
}

Dies sorgt dafür, dass die App später die aufgeführten Architekturen unterstützt. Wichtig dabei: Ab 1. August ist Unterstützung der 64-Bit-Architekturen seitens Google Play obligatorisch!

sourceSets {
        main {
            jniLibs.srcDirs = ['lib']
        }
    }

Hiermit wird das Verzeichnis für die nativen Libs festgelegt. Aber woher kommen die?

task copyAndroidNatives {
    doFirst {
        file("lib/armeabi/").mkdirs()
        file("lib/armeabi-v7a/").mkdirs()
        file("lib/arm64-v8a/").mkdirs()
        file("lib/x86_64/").mkdirs()
        file("lib/x86/").mkdirs()

        configurations.natives.files.each { jar ->
            def outputDir = null
            if (jar.name.endsWith("natives-arm64-v8a.jar")) outputDir = file("lib/arm64-v8a")
            if (jar.name.endsWith("natives-armeabi-v7a.jar")) outputDir = file("lib/armeabi-v7a")
            if(jar.name.endsWith("natives-armeabi.jar")) outputDir = file("lib/armeabi")
            if(jar.name.endsWith("natives-x86_64.jar")) outputDir = file("lib/x86_64")
            if(jar.name.endsWith("natives-x86.jar")) outputDir = file("lib/x86")
            if(outputDir != null) {
                copy {
                    from zipTree(jar)
                    into outputDir
                    include "*.so"
                }
            }
        }
    }
}

tasks.whenTaskAdded { packageTask ->
    if (packageTask.name.contains("package")) {
        packageTask.dependsOn 'copyAndroidNatives'
    }
}

So wird Gradle beigebracht, die nativen Bibliotheken an Ort und Stelle abzulegen. (Man kann das später in Android Studio mit Analyze/APK… nachschauen.)

Fehlt noch:

configurations {
    natives
}
dependencies {
   ...
    implementation "com.badlogicgames.gdx:gdx-box2d:$gdxVersion"
    natives "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-armeabi"
    natives "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-armeabi-v7a"
    natives "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-arm64-v8a"
    natives "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86"
    natives "com.badlogicgames.gdx:gdx-box2d-platform:$gdxVersion:natives-x86_64"
}

Tatsächlich binde ich hier NUR gdx-box2d ein, und nicht die Hauptbibliothek libgdx selbst, die ich nicht brauche.

Die zu verwendende gdxVersion lege ich in der hierarchisch obersten build.gradle fest:

allprojects {
    repositories {
        google()
        jcenter()
        mavenCentral()
    }
    ext {
        gdxVersion = '1.9.9'
    }
}

Und nun zur Pointe: Die native Version der Bibliothek ist grob geschätzt um einen Faktor 25 schneller. Sogar das lahme-Ente-Tablet aus China erreicht damit eine spielbare Framerate:

Mit ins Bild geschummelt hat sich das mitgelieferte OTG-Kabel

Ein schnelleres Tablet oder Phone kommt locker auf 1ms Rechenaufwand für die Physik (also world.step()), während das Zeichnen “nur” um einen Faktor 3 schneller ist. Aber da lässt sich sicher auch noch was optimieren, das soll ein andermal erzählt werden.

Der Teufel steckt aber mal wieder im Detail. Während jBox2D und box2d selbst ihre physikalischen Objekte (bodies) in verketteten Listen verwalten, benutzt der libgdx-Wrapper zusätzlich eine LongMap. Deren Schlüssel sind aber nicht sortiert, daher geht die Reihenfolge der Bodies zwischen Hinzufügen und Zeichnen verloren! Da keine Möglichkeit vorgesehen ist, eine z-Ebene mitzugeben, kann es passieren, dass Objekte vor anderen (also später) gezeichnet werden, die eigentlich dahinter gezeichnet werden sollen (also früher). Da hat wohl jemand tief geschlafen, als er den Wrapper implementiert hat. Mal sehen, wie ich das löse. Es bleibt spannend.

TILT: Hier geht die Reihenfolge verloren, in der die Bodys hinzugefügt werden, da der Schlüssel der verwendeten LongMap keine Reihenfolge kennt (World.java).

Fazit: Finger weg von jBox2D, sondern gleich die native Version nehmen. Schneller ist schöner.

Ich bedanke mich für die Aufmerksamkeit und gehe mal besser coden.