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
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