OpenGL - Wie kommt es, dass das Zeichnen von Sprites so viel Leistung erfordert?

7

Ich frage mich, wie das Zeichnen einfacher Geometrie mit Texturen so viel Leistung verschlingen kann (unter 60 fps). Selbst meine gute Grafikkarte (GTX 960) kann "nur" problemlos bis zu 1000 Sprites zeichnen. Die Texturen, die ich verwende, bestehen alle aus 2 Texturen und dürfen eine Größe von 512 x 512 nicht überschreiten. Ich filtere sogar GL_NEARESTnur mit.
Die Sprites selbst sind zufällig in der Größe generiert. Es gibt also keine 1000 Vollbild-Quads, was kein wirklicher Anwendungsfall wäre.

Ich zeichne meine Sprites gestapelt, was bedeutet, dass ich einen dynamischen Scheitelpunktpuffer und einen statischen Indexpuffer habe. Ich aktualisiere den Scheitelpunktpuffer jedes Bild mit glBufferSubDataeinmal und zeichne dann alles mit `` glDrawElements`. Ich habe ungefähr 5 verschiedene Texturen, die ich einmal pro Frame binde, was zu 5 Draw Calls führt. Zum Rendern verwende ich nur einen Shader, der gebunden ist, sobald die Anwendung gestartet wird.
Ich habe also 5 Texturbindungen, 5 Zeichenaufrufe und ein Vertex-Puffer-Update pro Frame, was nicht wirklich so viel ist.

Hier ist ein Beispiel mit einer Textur:

val shaderProgram = ShaderProgram("assets/default.vert", "assets/default.frag")
val texture = Texture("assets/logo.png")
val sprite = BufferSprite(texture)
val batch = BufferSpriteBatch()

val projView = Matrix4f().identity().ortho2D(0f, 640f, 0f, 480f)

fun setup() {
    glEnable(GL_TEXTURE)
    //glColorMask(true, true, true, true)
    //glDepthMask(false)

    glUseProgram(shaderProgram.program)
    texture.bind()

    batch.begin()
        for(i in 1..1000)
            batch.draw(sprite)
    batch.update()
}

fun render() {
    glClear(GL_COLOR_BUFFER_BIT)

    stackPush().use { stack ->
        val mat = stack.mallocFloat(16)
        projView.get(mat)
        val loc = glGetUniformLocation(shaderProgram.program, "u_projView")
        glUniformMatrix4fv(loc, false, mat)

        batch.flush()
    }

}

Die batch.draw()Methode legt die Scheitelpunktdaten der Sprites in einem CPU-seitigen Puffer ab und batch.update()lädt alles mit auf die GPU hoch glBufferSubData. Das Einrichten des Spritebatch sieht folgendermaßen aus:

glBindBuffer(GL_ARRAY_BUFFER, tmpVbo)
            glBufferData(GL_ARRAY_BUFFER, vertexData, GL_STATIC_DRAW)
            glEnableVertexAttribArray(0)
            glEnableVertexAttribArray(1)
            glEnableVertexAttribArray(2)
            glVertexAttribPointer(0, 2, GL_FLOAT, false, 24 * sizeof(Float), 0)
            glVertexAttribPointer(1, 4, GL_FLOAT, false, 24 * sizeof(Float), 2.toLong() * sizeof(Float))
            glVertexAttribPointer(2, 2, GL_FLOAT, false, 24 * sizeof(Float), 6.toLong() * sizeof(Float))

            glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, tmpEbo)
            glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices, GL_STATIC_DRAW)

Ich habe zuerst mein Programm profiliert, aber das Aktualisieren der Scheitelpunktpuffer und der gesamten Geometrie dauert ungefähr 10% der Gesamtzeit pro Frame. Das Austauschen der Puffer nimmt jedoch den Rest der 90% -Rahmenzeit in Anspruch.

Ich frage mich also, wie können so große AAA-Spiele ihre Szenen mit Millionen von Eckpunkten rendern, wenn das Zeichnen von Pixeln eine so zeitaufwändige Aufgabe ist? Ich weiß, dass es viele Optimierungen im Code gibt, aber immer noch.

mrdlink
quelle
3
Wenn Sie genau angeben, was Sie tun, können Sie höchstwahrscheinlich individuellere Antworten erhalten. Im Moment ist nicht klar, ob Sie sich fragen, wie es normalerweise gemacht wird (Körper) oder warum Ihr spezieller Fall schlecht abschneidet (Titel). Nach Ihrem Kommentar zu urteilen, sind Sie nach der ersten Option, geben aber ziemlich vage Informationen über Ihre aktuelle Lösung.
Wondra
Bitte geben Sie ein minimales, vollständiges und überprüfbares Beispiel an . Laut dem letzten Kommentar können wir, wenn wir nicht genau wissen , was Sie tun, nur fundierte Vermutungen anstellen.
Pikalek
Wie funktionieren andere OpenGL-Programme auf Ihrem Computer? Können Sie etwas wie Quake oder Quake II ausprobieren? Sie zeichnen mehr als 1000 Polygone pro Frame und sollten Ihnen eine Vorstellung von der Art der Leistung geben, die Sie erwarten sollten.
Maximus Minimus
Mit der erwähnten GTX 960 arbeiten andere Programme sehr gut. Alles um min. 60 fps. Aber ich kann ihren Code leider nicht ansehen und sehen, wie die Puffer- und Speicherverwaltung dort funktioniert.
mrdlink

Antworten:

16

Ihre GPU kann wahrscheinlich sogar 100.000 Sprites ohne Probleme rendern, aber Sie müssen es intelligent machen. Sprites und andere Geometrien müssen einer GPU in Stapeln bereitgestellt werden, die nach derselben Textur, demselben Shader und demselben Mischmodus gruppiert sind.

Große AAA-Spiele minimieren die an die GPU ausgegebenen Draw Calls. Zeichenaufrufe sind normalerweise teuer , daher werden viele ähnliche Zeichenvorgänge zusammengefasst und stapelweise an die GPU gesendet. Jede neue Änderung des Shader-, Textur- oder Mischmodus während des Renderns führt zu einem separaten Zeichenaufruf. Außerdem werden Texturatlanten verwendet, um Zeichnungsaufrufe zu reduzieren (viele Bilder auf einer einzelnen Textur).

HankMoody
quelle
3
Ja das. Es gibt einen richtigen und einen falschen Weg, um Sprites zu zeichnen, und der falsche Weg ist sehr OO, verwendet eine Sprite-Klasse, jedes Sprite-Objekt enthält seinen eigenen Scheitelpunktpuffer, berechnet seine Positionen auf der CPU in jedem Frame neu und aktualisiert diesen Scheitelpunktpuffer in jedem Frame Es gibt also viele Statusänderungen und Synchronisierungsaufwand. Wenn Sie etwas Unverbindliches einwerfen, haben Sie die Anzahl der Statusänderungen verdoppelt und sehen, wie 1000 Sprites eine moderne GPU in die Knie zwingen können. Der OP-Code enthält wahrscheinlich einige oder alle der gerade beschriebenen Informationen. es ist häufig genug.
Maximus Minimus
Aber genau das mache ich. Ich habe nur einen Scheitelpunktpuffer für alle Sprites, die einen Draw-Aufruf pro Textur ausgeben. Ich habe nur 5 verschiedene Texturen und nur einen Shader.
mrdlink
1
@mrdlink, wenn Sie Feedback zur Verbesserung Ihrer spezifischen Methode wünschen, einschließlich Codebeispielen in Ihrer Frage, ist der beste Weg, um sicherzustellen, dass jeder versteht, was Ihr Spiel gerade tut. Vor allem, wenn Sie sich die Zeit nehmen, sie auf die minimal relevanten Teile zu bearbeiten, damit die Leute nicht über Hunderte von Zeilen blättern müssen, bevor sie dieses Verständnis erlangen können.
DMGregory
@mrdlink Füge die Texturen in einen einzigen Atlas ein. Sie sollten die Textur einmal pro Frame für ALLE Sprites binden, die gerendert werden
Ich denke, der Artikel ist etwas irreführend. Ein Draw-Aufruf bedeutet per Definition weder eine Statusänderung noch eine Verschlechterung der Leistung. Sie können mehrere Objekte in separaten Zeichenaufrufen zeichnen, ohne den Status zu ändern. In Fällen von Sprite-Batching werden Draw-Aufrufe in Bezug auf die Leistung deutlicher, da der einzige Grund für einen Sprite-Batcher, einen neuen Drawcall durchzuführen, darin besteht, dass sich die Textur oder der Shader geändert hat (oder wenn der Puffer voll ist, aber keine Änderung in der Textur oder im Shader). . Die Anzahl der Drawcalls kann irreführend sein. Wenn Sie Renderdoc verwenden , können Sie sehen, wie Unity / Unreal dies tun.
Sidar
1

Die Art und Weise, wie Sie Ihre Sprites stapeln, ist möglicherweise nicht optimal. Wenn Sie glDrawElements()einen Stapel mehrerer Sprites rendern, kann dies nur bedeuten, dass Sie 4 Scheitelpunkte pro Quad in Ihrem VBO speichern (andernfalls sehe ich nicht, wie glDrawElements()allein mehrere Sprites gleichzeitig gerendert werden können. Ich könnte mich irren, in In diesem Fall können Sie mich gerne korrigieren.

Die richtige Lösung hängt auch von Ihrem Anwendungsfall ab - es ist nicht unbedingt dasselbe für ein Partikelsystem im Vergleich zum allgemeinen 2D-Rendering für ein Spiel.

Die Sache ist: Wir brauchen keine Indizes und wir müssen auch nicht 4 Scheitelpunktpositionen pro Quad speichern.
Durch die Verwendung von weniger Speicher würden wir die Datenmenge reduzieren, die pro Frame aktualisiert werden soll, und die Anzahl der Speicherzugriffe verringern, die als langsam angenommen werden sollten.

Was ich tun würde, ist instanziiertes Rendern .
Grundsätzlich kann Ihr Problem als Rendern eines einzelnen Quad-Netzes beschrieben werden, jedoch 1000-mal mit jeweils unterschiedlichen Einstellungen (einschließlich Transformationen und erforderlichen Informationen für Textur-Lookups).
Wenn Sie wissen, dass Ihre Quads immer zum Bildschirm zeigen, können Sie es sich sogar leisten, weniger Informationen an die GPU zu senden (z. B. Positionen wie vec2, Rotationen als einzelner Schwimmer usw.).

Hier ist ein sehr sehr grober Pseudocode für das instanziierte Rendern. Ausführliche Tutorials und Erklärungen zu dieser Technik sind weit verbreitet, und ich empfehle dringend, einige nachzuschlagen.


// When setting up attrib pointers.
// See https://www.khronos.org/opengl/wiki/Vertex_Specification#Instanced_arrays
glVertexAttribDivisor(attribQuadCenter​, 1);
glVertexAttribDivisor(attribQuadScale​, 1);
glVertexAttribDivisor(attribTextureUnit​, 1);
glVertexAttribPointer(attribQuadCenter, ....);
glVertexAttribPointer(attribQuadScale, ....);
glVertexAttribIPointer(attribTextureUnit, .....);

glBufferSubData(...) // Supply all positions, scales and texture unit values.

// Rendering
for(int i=0 ; i<5 ; ++i) {
    glActiveTexture(GL_TEXTURE0 + i);
    glBindTexture(GL_TEXTURE_2D, textures[i]);
}
// Render absolutely all sprites in a single draw call.
glBindVertexArray(quad_vao);
glDrawArraysInstanced(GL_TRIANGLE_FAN, 0, 4, 1000);

Eine andere Technik, die Sie untersuchen könnten, ist Point Sprites .
Bei Punkt-Sprites wird ein einzelner Scheitelpunkt pro Sprite "gezeichnet". Jeder Scheitelpunkt wird dann zu einem Bildschirmraum-Quad erweitert, und Sie können sein Erscheinungsbild mithilfe des Fragment-Shaders anpassen, indem Sie normalisierte Koordinaten innerhalb dieses Quad geben (z. B. Textur-Lookups durchführen).
Die Größe des Bildschirmraum-Quad kann im Vertex-Shader (wo Sie ihn auch durch z teilen können) beschrieben werden, wie im verlinkten Artikel erläutert.

Eine Reihe anderer Dinge, die Sie ausprobieren und profilieren sollten:

  • Versuchen Sie, den Tiefentest umzuschalten. Wenn Sie es sich leisten können, sollte die Aktivierung einen Schub geben.
  • Erwägen Sie, große Sprites auszusortieren, die von der Kamera nicht gesehen werden können. Ich vermute jedoch, dass es sich nicht lohnt, dies zu tun, es sei denn, Sie arbeiten mit mehr als 10.000 Sprites oder so.
  • Das Aufrufen von glBufferSubData () für jeden Frame zum Aktualisieren von Daten für jedes Sprite ist wahrscheinlich langsam und schlecht skalierbar. Speicherübertragungen von der CPU zur GPU sind kostspielig, weshalb wir jetzt Vertex-Puffer anstelle der alten Pipeline mit festen Funktionen haben.
    Wenn sich Ihr Anwendungsfall dafür eignet, möchten Sie möglicherweise einen Compute Shader verwenden, um das VBO direkt über die GPU zu aktualisieren (dies ist etwas aufwändiger und es gibt großartige Online-Ressourcen, die dies besser erklären als ich).
Yoan Lecoq
quelle
Ich denke, das Problem sind nicht die Vertex-Puffer-Updates oder die Synchronisation mit CPU, GPU und Treiber. Ich habe versucht, alles mit einem statischen VBO zu zeichnen, und es funktioniert immer noch genauso. PointSprites sind keine Option, da ich nicht möchte, dass meine Sprites quadratisch sind.
mrdlink
1
@mrdlink Wie andere gesagt haben, würden wir wahrscheinlich davon profitieren, Code und vielleicht einen Screenshot zu sehen, damit wir bessere Vorschläge machen können. Wie viel Bildschirmfläche decken diese Sprites insgesamt ab? Muss gemischt werden? Verwenden Sie den proprietären Treiber? Folgendes würde ich als Nächstes versuchen: Reduzieren Sie die Texturgröße auf etwa 32 x 32 und prüfen Sie, ob sich dadurch etwas verbessert (wenn dies der Fall ist, sollten Sie glGenerateMipmap () mit den 512 x 512-Texturen verwenden). Wenn Sie dies noch nicht getan haben, sollten Sie auch versuchen, das Keulen auf der Rückseite zu aktivieren. Und ich denke, mir gehen hier die Ideen aus. :)
Yoan Lecoq
Bei 1000 Sprites ist es sehr unwahrscheinlich, dass dies ein Engpass ist.
Maximus Minimus
@ MaximusMinimus Dem würde ich zustimmen. Ich bin immer noch nicht so erfahren, wenn es um Perfektion in Grafik-APIs geht. :)
Yoan Lecoq