7
\$\begingroup\$

I'm wondering how drawing simple geometry with textures can eat up so much performance (below 60fps)? Even my good graphics card (GTX 960) can "only" draw up to 1000 sprites smoothly. The textures I'm using are all power of 2 textures and don't exceed a size of 512x512. I'm even filtering with GL_NEAREST only.
The sprites themself are random generated in size. So there are no 1000 fullscreen quads, which would be no real use case.

I'm drawing my sprites batched, meaning I have one dynamic vertex buffer and a static index buffer. I update the vertex buffer every frame with glBufferSubData once and then draw everything with ``glDrawElements`. I have about 5 different textures which I bind once per frame resulting in 5 draw calls. For rendering I'm using only one shader which is bound once the application starts.
So I have 5 texture bindings, 5 draw calls, and one vertex buffer update per frame which is not really that much.

Here is an example with one texture:

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()
    }

}

The batch.draw() method puts the sprites vertex data in a cpu side buffer and batch.update() uploads everything to the gpu with glBufferSubData. And setting up the spritebatch looks as follows:

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)

I was profiling my program first, but updating the vertex buffers and all the geometry takes about 10% of the total time per frame. But swapping the buffers takes up the rest of the 90% frame time.

So I'm asking, how can such big AAA games render their scenes with millions of vertices, if drawing pixels is such a time consuming task? I know that their is a lot of optimizations in the code, but still.

\$\endgroup\$
  • 3
    \$\begingroup\$ Including what exactly are you doing would most likely allow more personalized answers. Right now it is not clear whether you are asking about how is it usually done(body) or why your particular case performs poorly(title). Judging from your comment you are after the 1st option but providing rather vague information about your current solution. \$\endgroup\$ – wondra Mar 3 '18 at 9:37
  • \$\begingroup\$ Please include a minimal, complete, and verifiable example. As per last comment, if we don't know exactly what you are doing, the best we can do is take educated guesses. \$\endgroup\$ – Pikalek Mar 3 '18 at 15:58
  • \$\begingroup\$ How do other OpenGL programs perform on your computer? Can you try something like Quake or Quake II - they draw 1000+ polygons per frame and should give you an idea of the kind of performance you should be expecting. \$\endgroup\$ – Maximus Minimus Mar 3 '18 at 18:05
  • \$\begingroup\$ With the mentioned GTX 960 other programs perform very well. All at min. 60fps. But I can't look at their code unfortunately and see how buffer and memory management works there. \$\endgroup\$ – mrdlink Mar 4 '18 at 0:38
15
\$\begingroup\$

Your GPU can probably render even 100k sprites without issues, but you need to do it smart. Sprites and other geometry must be supplied to a GPU in batches grouped by the same texture, shader and blend mode.

Big AAA games are minimizing draw calls issued to the GPU. Draw calls are usually expensive, so many similar drawing operations are grouped together and send to GPU in batches. Each new shader, texture or blend mode change during rendering results in a separate draw call. Also Texture atlases are used to reduce draw calls (many images on a single texture).

\$\endgroup\$
  • 3
    \$\begingroup\$ Yes, this. There's a right way and a wrong way to draw sprites, and the wrong way is very OO, uses a sprite class, each sprite object contains it's own vertex buffer, recalculates it's positions on the CPU each frame, and updates that vertex buffer each frame, so there's lots of state changes and synchronization overhead; throw in some unbinding and you've doubled the number of state changes, and you start to see how 1000 sprites can drag a modern GPU to it's knees. The OP's code likely has some or all of what I've just described; it's common enough. \$\endgroup\$ – Maximus Minimus Mar 3 '18 at 2:26
  • \$\begingroup\$ But thats exactly what im doing. I have only one vertex buffer for all the sprites issuing one draw call per texture. Any i have only 5 different textures and only one shader. \$\endgroup\$ – mrdlink Mar 3 '18 at 8:29
  • 1
    \$\begingroup\$ @mrdlink if you want feedback on improving your specific method, including code samples in your question is the best way to ensure everyone understands what your game is doing currently. Especially if you take the time to edit them to the minimal relevant portions, so folks don't need to pore over hundreds of lines before they can glean that understanding. \$\endgroup\$ – DMGregory Mar 3 '18 at 13:07
  • \$\begingroup\$ @mrdlink Put the textures in one single atlas. You should bind the texture once every frame, for ALL the sprites that are being rendered \$\endgroup\$ – user100681 Mar 3 '18 at 13:10
  • \$\begingroup\$ I feel like the article is a bit misleading. A draw call doesn't mean by definition a state change, nor does it mean degradation of performance. You can draw several objects in separate draw calls without changing state. In cases of Sprite Batching draw calls become more apparent on performance because the only reason for a sprite batcher to make a new drawcall is if the texture or shader has changed (or if the buffer is full, but no change in texture or shader tho). Drawcall count can be misleading.If you use Renderdoc you can see how Unity/Unreal do it. \$\endgroup\$ – Sidar Mar 3 '18 at 14:05
1
\$\begingroup\$

The way you are batching your sprites might be suboptimal. If you're using glDrawElements() to render a batch of multiple sprites, then that can only mean you're storing 4 vertices per quad in your VBO (otherwise I fail to see how glDrawElements() alone could render several sprites at once. I might be wrong, in which case feel free to correct me).

The right solution also depends on your use case - it isn't necessarily the same for a particle system vs. general 2D rendering for a game.

The thing is : we don't need indices, and we don't need to store 4 vertex positions per quad either.
By using less memory, we would reduce the amount of data to update per frame, and reduce the number of memory accesses, which should be assumed to be slow.

What I would do is Instanced Rendering.
Basically, your problem can be described as rendering a single quad mesh, but 1000 times with different settings each (including transforms and necessary information for texture lookups).
Also, if you know that your quads always face the screen, you can even afford to send less information to the GPU (eg. positions as vec2, rotations as a single float, etc).

Here's a very very rough pseudocode for instanced rendering. In-depth tutorials and explanations of this technique are widely available and I highly recommend looking some up.


// 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);

Another technique you could look into is Point Sprites.
Point Sprites involve "drawing" a single vertex per sprite; each vertex then gets expanded into a screen-space quad, and you can tweak its appearance using the fragment shader, given normalized coordinates within that quad (eg. perform texture lookups).
The size of the screen-space quad can be written to in the vertex shader (where you could also divide it by z), as explained in the linked article.

A bunch of other things to try and profile :

  • Try toggling the depth test. If you can afford to, enabling it should give you a boost.
  • Consider culling large sprites that can't be seen by the camera. However, I suspect this is not worth doing unless you work with more that 10k sprites or so.
  • Calling glBufferSubData() every frame for updating data for every sprite is likely to be slow and scale poorly; Memory transfers from the CPU to the GPU are costly, which is why we now have vertex buffers instead of the old fixed function pipeline.
    If your use case lends itself to it, you might want to use a Compute Shader to update the VBO directly using the GPU (this is a bit more involved and there are great online resources that would explain this better than I could).
\$\endgroup\$
  • \$\begingroup\$ I think the problem isn't the vertex buffer updates or synchronisation with cpu, gpu and driver. I tried drawing everything with a static VBO and it still performs the same. PointSprites are no option, since I don't want my sprites to be quadratic. \$\endgroup\$ – mrdlink Mar 3 '18 at 13:26
  • 1
    \$\begingroup\$ @mrdlink As others have said, I think we would benefit from seeing some code and maybe a screenshot, so we can offer better suggestions. How much total screen space do these sprites cover ? Is there a need for blending ? Are you using the proprietary driver ? Here's what I would try next : reduce the texture size to something like 32x32 and see if it improves anything (if it does, consider using glGenerateMipmap() with the 512x512 textures). Also, if you haven't done so already, you should try to enable back-face culling. And, well I guess I'm running out of ideas here. :) \$\endgroup\$ – Yoan Lecoq Mar 3 '18 at 14:20
  • \$\begingroup\$ For 1000 sprites this is very unlikely to be a bottleneck. \$\endgroup\$ – Maximus Minimus Mar 3 '18 at 18:11
  • \$\begingroup\$ @MaximusMinimus I would agree to that. I'm still not that experienced when it comes to perf in graphics APIs. :) \$\endgroup\$ – Yoan Lecoq Mar 3 '18 at 18:46

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.