1
votes

this is my first post so apologies if I have made any mistakes etc. If at the end of the post, you require more information then I'll be more then happy to post more.

I've been working on 2D tile based game for a while now and I've recently tried a different approach to rendering the tiles. But right now I have a performance issue when rendering tiles.

The game has its world split into 16*256 chunks and each chunk has its own RenderTarget2D (16*256) to hold the tile data. I've called the render target the chunks buffer.

    public void updateChunks()
    {
        int loadMinX = (int)( ( game.camera.location.X - game.GraphicsDevice.Viewport.Width / 2 ) / Tile.tileWidth / Chunk.chunkWidth );
        int loadMaxX = (int)( ( game.camera.location.X + game.GraphicsDevice.Viewport.Width / 2 ) / Tile.tileWidth / Chunk.chunkWidth );

        int minX = (int)( game.player.location.X / ( Chunk.chunkWidth * Tile.tileWidth ) ) - (int)Math.Floor( chunkCount / 2f );
        int maxX = (int)( game.player.location.X / ( Chunk.chunkWidth * Tile.tileWidth ) ) + (int)Math.Floor( chunkCount / 2f );

        Chunk currentChunk;
        int cursorChunk = game.cursor.chunkLocation;

        for( int c = minX; c <= maxX; c++ )
        {
            if( chunkBuffer.ContainsKey( c ) )
            {
                currentChunk = chunkBuffer[c];

                if( c >= loadMinX && c <= loadMaxX )
                {
                    currentChunk.bufferBuilt = false;
                }

                if( currentChunk.highlighted == true )
                {
                    currentChunk.highlighted = false;
                    currentChunk.bufferBuilt = false;
                }

                if( chunkBuffer.ContainsKey( cursorChunk ) && chunkBuffer[cursorChunk] == currentChunk )
                {
                    currentChunk.highlighted = true;
                    currentChunk.bufferBuilt = false;
                }

                if( currentChunk.bufferBuilt == false )
                {
                    RebuildChunk( currentChunk );
                }
            }
        }

        game.tilesLoaded = chunkBuffer.Count * Chunk.chunkWidth * Chunk.chunkHeight;
    }

This method is called every tick and decides which chunks buffer needs to be rebuilt/redrawn. Currently, the chunks that are in the viewport are rebuilt every tick and also the chunk that the mouse is inside is rebuilt as well (because the tile changes to highlighted colour.)

The chunks are stored inside a dictionary and the loaded chunks are accessed via looping through minX -> minY values of the dictionary of chunks, as shown in the updateChunks() method.

    public void RebuildChunk( Chunk chunk )
    {
        BuildChunk( chunk );

        chunk.bufferBuilt = true;
    }

    private void BuildChunk( Chunk chunk )
    {
        int chunkWidth = Chunk.chunkWidth;
        int chunkHeight = Chunk.chunkHeight;
        int chunkLocation = chunk.id * chunkWidth;

        int loadMinX = (int)( ( game.camera.location.X - game.GraphicsDevice.Viewport.Width / 2 ) / Tile.tileWidth ) - 2;
        int loadMaxX = (int)( ( game.camera.location.X + game.GraphicsDevice.Viewport.Width / 2 ) / Tile.tileWidth ) + 2;
        int loadMinY = (int)( ( game.camera.location.Y - game.GraphicsDevice.Viewport.Height / 2 ) / Tile.tileHeight ) - 2;
        int loadMaxY = (int)( ( game.camera.location.Y + game.GraphicsDevice.Viewport.Height / 2 ) / Tile.tileHeight ) + 2;

        game.GraphicsDevice.SetRenderTarget( chunk.buffer );
        game.GraphicsDevice.Clear( Color.CornflowerBlue );

        game.spriteBatch.Begin( SpriteSortMode.Immediate, BlendState.NonPremultiplied );

        for( int x = 0; x < chunkWidth; x++ )
        {
            for( int y = 0; y < chunkHeight; y++ )
            {
                if( x + chunkLocation > loadMinX && x + chunkLocation < loadMaxX && y > loadMinY && y < loadMaxY )
                {
                    if( chunk.tiles[x, y].type == 0 && chunk.tiles[x, y].lightSource == false )
                    {
                        chunk.tiles[x, y].lightSource = true;
                        chunk.tiles[x, y].lightComponent = new Light( game, new Point( chunkLocation + x, y ), 1.0f, 6 );
                    }

                    if( chunk.tiles[x, y].lightSource == true )
                    {
                        if( chunk.tiles[x, y].lightComponent == null )
                        {
                            chunk.tiles[x, y].lightSource = false;
                        }
                        else
                        {
                            ApplyLighting( chunk, x, y, chunk.tiles[x, y].lightComponent );
                        }
                    }

                    float brightness = chunk.tiles[x, y].brightness;

                    if( chunk.tiles[x, y].type == 0 )
                    {
                        if( chunk.highlighted == true && game.cursor.tileLocation.X == x && game.cursor.tileLocation.Y == y )
                        {
                            game.spriteBatch.Draw( game.gameContent.airTexture, new Rectangle( x * Tile.tileWidth, y * Tile.tileHeight, Tile.tileWidth, Tile.tileHeight ), new Color( 0.5f, 0.5f, 0.5f ) );
                        }
                        else
                        {
                            game.spriteBatch.Draw( game.gameContent.airTexture, new Rectangle( x * Tile.tileWidth, y * Tile.tileHeight, Tile.tileWidth, Tile.tileHeight ), new Color( brightness, brightness, brightness ) );
                        }
                    }
                    else if( chunk.tiles[x, y].type == 1 )
                    {
                        if( chunk.highlighted == true && game.cursor.tileLocation.X == x && game.cursor.tileLocation.Y == y )
                        {
                            game.spriteBatch.Draw( game.gameContent.dirtTexture, new Rectangle( x * Tile.tileWidth, y * Tile.tileHeight, Tile.tileWidth, Tile.tileHeight ), new Color( 0.5f, 0.5f, 0.5f ) );
                        }
                        else
                        {
                            game.spriteBatch.Draw( game.gameContent.dirtTexture, new Rectangle( x * Tile.tileWidth, y * Tile.tileHeight, Tile.tileWidth, Tile.tileHeight ), new Color( brightness, brightness, brightness ) );
                        }
                    }
                }
            }
        }

        game.spriteBatch.End();

        game.GraphicsDevice.SetRenderTarget( null );
    }

RebuildChunk is responsible for building the chunk again and resetting the built flag so cpu time isn't wasted rebuilding chunks that haven't been edited or that aren't in the viewport.

BuildChunk is where the tiles are drawn to the chunks buffer. The render target is swapped to the chunks buffer and then it loops through the chunks tiles.

if( x + chunkLocation > loadMinX && x + chunkLocation < loadMaxX && y > loadMinY && y < loadMaxY )

This line checks if the tile is in the viewport. If it is, then check to see what the tiles type is and then draw the corresponding texture for that tile. There's also lighting logic in there too but it does not affect my problem.

My main rendering logic is:

protected override void Draw( GameTime gameTime )
    {
        frameCounter++;

        drawScene( gameTime );

        GraphicsDevice.Clear( Color.White );

        spriteBatch.Begin( SpriteSortMode.Immediate, BlendState.NonPremultiplied );

        spriteBatch.Draw( white, new Rectangle( 0, 0, GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height ), Color.CornflowerBlue );
        spriteBatch.Draw( worldBuffer, new Rectangle( 0, 0, GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height ), Color.White );

        spriteBatch.End();

        debugUI.Draw( gameTime );

        base.Draw( gameTime );
    }

    private void drawScene( GameTime gameTime )
    {
        int loadMinX = (int)( ( camera.location.X - GraphicsDevice.Viewport.Width / 2 ) / Tile.tileWidth / Chunk.chunkWidth );
        int loadMaxX = (int)( ( camera.location.X + GraphicsDevice.Viewport.Width / 2 ) / Tile.tileWidth / Chunk.chunkWidth );

        int minX = (int)( player.location.X / ( Chunk.chunkWidth * Tile.tileWidth ) ) - (int)Math.Floor( worldManager.chunkCount / 2f );
        int maxX = (int)( player.location.X / ( Chunk.chunkWidth * Tile.tileWidth ) ) + (int)Math.Floor( worldManager.chunkCount / 2f );

        GraphicsDevice.SetRenderTarget( worldBuffer );
        GraphicsDevice.Clear( Color.CornflowerBlue );

        spriteBatch.Begin( SpriteSortMode.Immediate, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, camera.GetViewTransformation( GraphicsDevice ) );

        for( int c = minX; c <= maxX; c++ )
        {
            if( c >= loadMinX && c <= loadMaxX )
            {
                if( worldManager.chunkBuffer.ContainsKey( c ) )
                {
                    Chunk currentChunk = worldManager.chunkBuffer[c];

                    if( currentChunk.bufferBuilt == true )
                    {
                        spriteBatch.Draw( currentChunk.buffer, new Rectangle( c * Chunk.chunkWidth * Tile.tileWidth, 0, Chunk.chunkWidth * Tile.tileWidth, Chunk.chunkHeight * Tile.tileHeight ), Color.White );
                    }
                }
            }
        }

        spriteBatch.Draw( player.material, new Rectangle( (int)player.location.X, (int)player.location.Y, 16, 32 ), Color.White );

        spriteBatch.End();
        GraphicsDevice.SetRenderTarget( null );
    }

Finally, inside my input manager I have the following:

if( mouseState.LeftButton == ButtonState.Pressed )
        {
            game.worldManager.RemoveTile( game.cursor.chunkLocation, game.cursor.tileLocation );
        }

which calls:

public void RemoveTile( int chunkLocation, Point location )
    {
        if( chunkBuffer.ContainsKey( chunkLocation ) && location.X >= 0 && location.X < Chunk.chunkWidth && location.Y >= 0 && location.Y < Chunk.chunkHeight )
        {
            if( chunkBuffer[chunkLocation].tiles[location.X, location.Y].type != 0 )
                chunkBuffer[chunkLocation].tiles[location.X, location.Y].type = 0;
        }
    }

This is just my test method for changing the state of different tiles, in this case it changes the type form dirt to air to simulate removing a tile.

Now for the problem. When the game first runs it's fine and stays at 60fps. I move my character to the right to fill the screens width with tiles and then I dig down to fill the screens height with tiles.

http://i.imgur.com/NBJ8qRj.png

http://i.imgur.com/N9i8asW.png (note: lighting is turned off in the second image)

The game is still running fine in the first image, but when I start to remove tiles surrounding the player in the second image, after a while the framerate drops from 60 to below 10 and falls to around 2-3 fps and it stays at that rate forever and doesn't recover even though I've stopped clicking/doing anything to the game.

The problem occurs at full-screen resolutions and the performance analysis says a lot of cpu time is used during the draw calls inside the BuildChunk() method:

else
                    {
                        game.spriteBatch.Draw( game.gameContent.dirtTexture, new Rectangle( x * Tile.tileWidth, y * Tile.tileHeight, Tile.tileWidth, Tile.tileHeight ), new Color( brightness, brightness, brightness ) );
                    }

So could it be caused by the constant swapping of textures to draw? ie; swapping from drawing an airTexture to a dirtTexture etc? Because I've found that if I changed the airTexture to the dirtTexture as well, the issue goes away.

If I dig further down below all of the tiles I removed, the fps recovers back to 60 so my guess is it has something to do with drawing the textures in the rebuild phase.

If someone could look over this code and maybe point out any flaws with it that would be appreciated and possible solutions would be great.

Also, if someone has experience with this sort of situation with tiles and rendering your advice would be invaluable!

1
Nex time try to post only the relevant code, this question is huge and I don't know how many people will read it.pinckerman

1 Answers

3
votes

My goodness, this is a thorough question! Providing us with lots of information is appreciated, but in the future it may be a good idea to try to pare down your question to just those things which are directly relevant. People are going to be overwhelmed otherwise.

What I can tell you is that I think that you're on the right track with your analysis. Swapping textures back and forth can, as you've guessed, have a significant impact on performance.

One of the biggest restrictions on 2D rendering performance is to the so-called batch limit. Basically, every time you tell the graphics device to draw something--that is, every time you make a call to one of DirectX's DrawFooPrimitives functions--there's a certain fixed amount of overhead. Making lots and lots of draw calls which each only draw a small number of polygons is, therefore, very inefficient. Your GPU is sitting idle while your CPU churns away processing the draw calls!

XNA's SpriteBatch class exists to address this problem. It assembles lots of sprites into a single buffer which can be drawn with a single call to DrawIndexedPrimitives. However, it can only batch sprites if they all share the same graphics device state. This includes things like the sampler and rasterizer states you set in SpriteBatch.Begin(), but it also includes the sprite texture. Changing textures forces SpriteBatch to flush the current batch and start an entirely new one.

That said, you're making another related mistake that sort of renders the above point irrelevant. In your rendering method you're calling SpriteBatch.Begin() with SpriteSortMode.Immediate. This is going to be very inefficient for large numbers of sprites, because what you're doing is saying, "don't batch these at all; call DrawIndexedPrimitives for every single sprite I draw." A sprite is 4 vertices. Your graphics card can probably handle hundreds of thousands per Draw call. A huge waste!

So, to summarize, here's what I would change in your code:

  1. Change SpriteSortMode.Immediate to SpriteSortMode.Deferred. With this option, the SpriteBatch will accumulate the sprites you draw into a buffer, and then draw them all at once when you call End().
  2. Use a single texture for all of your tiles. This is usually done using what's called a texture atlas. You can specify a source rectangle in SpriteBatch.Draw() which will tell it to only use a small part of a larger image.