Search code examples
rubygame-developmentlibgosu

Optimization of Gosu Rendering Technique


I'm writing a game for the first time using the Gosu ruby gem as the graphics library. I think I understand generally that the logic for updating game state and the logic for updating / rendering should be segregated, and I've done my best efforts to remove as much workload from the rendering loop as possible.

My game is composed of 32x32 pixel tiles, and the screen width is 1920x1080, meaning that I'm rendering ~1,980 tiles to the screen at a time (sometimes more, depending on whether a particular tile has multiple sprites layered on it.

My issue is that although I feel that I've stripped almost all of the logic out of the draw method of the program, I still seem to be averaging about 22 frames per second. If anyone can take a look at the following code snippets and suggest possible reasons / optimizations for the slow performance, that would be very much appreciated!

# Main file (seven_deadly_sins.rb)
require_relative 'initializer'

class SevenDeadlySins < Gosu::Window
  WIDTH, HEIGHT = 1920, 1080

  def initialize
    super WIDTH, HEIGHT
    self.caption = "Seven Deadly Sins"

    @player = Player.new(0,0,10,40,40)
    @game_tiles = Gosu::Image.load_tiles('./assets/tiles/map_tiles.png', 16, 16, {retro: true}) # Retro means no weird border around smaller tiles
    @level_mapper = LevelMapper.new(self, 'test.json', @game_tiles)
  end

  def update
    @player.update
    @level_mapper.update
  end

  def draw
    @player.draw
    @level_mapper.draw
  end
end

SevenDeadlySins.new.show

# level_mapper.rb

class LevelMapper
  TILE_SIZE = 32

  def initialize(window, mapfile, sprites)
    @window = window
    @sprites = sprites
    @map = initialize_mapfile(mapfile)
    @font = Gosu::Font.new(16)
    @tiles_within_viewport = []
  end

  def update
    @tiles_within_viewport = @map.select {|tileset| within_viewport?(tileset[0]['x'], tileset[0]['y'], TILE_SIZE, TILE_SIZE)}
  end

  def draw
    @tiles_within_viewport.each do |tiles|
      tiles.each{|tile| draw_tile(tile)}
    end
  end

  def draw_tile(tile)
    Gosu.draw_rect(tile['x'], tile['y'], TILE_SIZE, TILE_SIZE, 0xff292634, 1)
    if tile['sprite_index']
      @sprites[tile['sprite_index']].draw(tile['x'], tile['y'], tile['z'], TILE_SIZE / 16, TILE_SIZE / 16)
    end
  end

  def needs_render?
    true
  end

  def initialize_mapfile(mapfile)
    contents = File.read(File.join(File.dirname(__FILE__), '..', 'assets', 'maps', mapfile))
    JSON.parse(contents)
  end

  def self.generate_empty_map(width, height, tile_size)
    max_tiles_x, max_tiles_y = width / tile_size * 6, height / tile_size * 6
    generated_map = (0..max_tiles_y).map {|y| (0..max_tiles_x).map {|x| [{x: x * tile_size, y: y * tile_size, z: 2}]}}.flatten(1)
    [max_tiles_x, max_tiles_y, generated_map]
  end

  def within_viewport?(x, y, w = 0, h = 0)
    x + w <= @window.width && y + h <= @window.height
  end
end

# player.rb
class Player < Humanoid
  def initialize(*opts)
    super(*opts)
  end
end

# humanoid.rb
class Humanoid
  attr_reader :bounding_box

  def initialize(x, y, z, w, h, move_speed = 5)
    @bounding_box = BoundingBox.new(x, y, z, w, h)
    @move_speed = move_speed
  end

  def draw
    if (needs_render?)
      Gosu.draw_rect(bounding_box.x, bounding_box.y, bounding_box.w, bounding_box.h, Gosu::Color::RED, bounding_box.z)
    end
  end

  def update
    if Gosu.button_down? Gosu::KB_LEFT or Gosu::button_down? Gosu::GP_LEFT
      move :left
    end
    if Gosu.button_down? Gosu::KB_RIGHT or Gosu::button_down? Gosu::GP_RIGHT
      move :right
    end
    if Gosu.button_down? Gosu::KB_UP or Gosu::button_down? Gosu::GP_BUTTON_0
      move :up
    end
    if Gosu.button_down? Gosu::KB_DOWN or Gosu::button_down? Gosu::GP_BUTTON_1
      move :down
    end
  end

  def needs_render?
    true
  end

  def move(direction)
    if direction == :left
      @bounding_box.x -= @move_speed
    elsif direction == :right
      @bounding_box.x += @move_speed
    elsif direction == :up
      @bounding_box.y -= @move_speed
    else
      @bounding_box.y += @move_speed
    end
  end
end

Solution

  • I ended up figuring this out, and thought I'd post my answer here in case anyone else runs into the same issue.

    Gosu has a function called record which takes saves your drawing operations, and gives it back to you as a drawable image (see #record). This is great for working with tilemaps (which is exactly what I'm doing in this post). I ended up pre-recording the draw operations in my initializer method, and then drawing the recording, like so:

    class LevelMapper
    
      def initializer
        ...
        # Pre-record map so that we can speed up rendering.
        create_static_recording
      end
    
      def create_static_recording
        @map_rec = @window.record(@window.width, @window.height) do |x, y|
          # Replace the following lines with whatever your draw function would do
          @tiles_within_viewport.each do |tiles|
            tiles.each do |tile|
              Gosu.draw_rect(tile['x'], tile['y'], TILE_SIZE, TILE_SIZE, 0xff292634, 1)
              if tile['sprite_index']
                @sprites[tile['sprite_index']].draw(tile['x'], tile['y'], tile['z'], TILE_SIZE / 16, TILE_SIZE / 16)
              end
            end
          end
        end
      end
    
      def draw
        @map_rec.draw(0, 0, 0)
      end
    
    end