Rendering 2.5 million Tiles In Dragonruby without dropping frames

Alright, I have a confession to make. We’re not actually going to “render 2.5 million tiles”. The final code is actually scanning the current viewport and only rendering the tiles a player can see. In reality, we’re actually holding 2.5 million tiles in memory in a hash of hashes. But we’ll get there. Instead, I want to go through the process of how I progressively made my code faster to avoid dropping frames and the techniques I used.

Most of the code besides the final code was me going by memory of what my iterations looked like.
I apologize if some of the examples don’t work, but I promise the final code does work!

The code we will start with is roughly from the Map Editor Camera Sample with some minor modifications. Namely, removing the map editor, and focusing on rendering tiles and a player and moving in a map. Here’s the rough start:

Full original source code. *Warning* its about 300 lines, and most of it isn't important.
Ruby
class Camera
  SCREEN_WIDTH = 1280
  SCREEN_HEIGHT = 720

  VIEWPORT_SIZE = 1500
  VIEWPORT_SIZE_HALF = VIEWPORT_SIZE / 2

  OFFSET_X = (SCREEN_WIDTH - VIEWPORT_SIZE) / 2
  OFFSET_Y = (SCREEN_HEIGHT - VIEWPORT_SIZE) / 2

  class << self
    def to_world_space camera, rect
      x = (rect.x - VIEWPORT_SIZE_HALF + camera.x * camera.scale - OFFSET_X) / camera.scale
      y = (rect.y - VIEWPORT_SIZE_HALF + camera.y * camera.scale - OFFSET_Y) / camera.scale
      w = rect.w / camera.scale
      h = rect.h / camera.scale
      rect.merge x: x, y: y, w: w, h: h
    end

    def to_screen_space camera, rect
      x = rect.x * camera.scale - camera.x * camera.scale + VIEWPORT_SIZE_HALF
      y = rect.y * camera.scale - camera.y * camera.scale + VIEWPORT_SIZE_HALF
      w = rect.w * camera.scale
      h = rect.h * camera.scale
      rect.merge x: x, y: y, w: w, h: h
    end

    def viewport
      {
        x: OFFSET_X,
        y: OFFSET_Y,
        w: 1500,
        h: 1500
      }
    end

    def viewport_world camera
      to_world_space camera, viewport
    end

    def find_all_intersect_viewport camera, os
      Geometry.find_all_intersect_rect viewport_world(camera), os
    end

    def scaled_screen_height scale
      SCREEN_HEIGHT / scale
    end

    def scaled_screen_width scale
      SCREEN_WIDTH / scale
    end
  end
end

def tick(args)
  args.state.symbol_to_path ||= {
    plain: "sprites/1-bit-platformer/0108.png",
    rock: "sprites/rock-1.png",
    tree: "sprites/tree-1.png"
  }
  args.state.play_scene ||= PlayScene.new
  args.state.current_scene ||= args.state.play_scene
  current_scene = args.state.current_scene

  args.state.current_scene.tick(args)

  # make sure that the current_scene flag wasn't set mid tick
  if args.state.current_scene != current_scene
    raise "Scene was changed incorrectly. Set args.state.next_scene to change scenes."
  end

  # if next scene was set/requested, then transition the current scene to the next scene
  if args.state.next_scene
    # set current scene for next tick.
    args.state.current_scene = args.state.next_scene
    args.state.next_scene = nil
  end

  args.outputs.debug << "Framerate: #{$gtk.current_framerate}"
  # args.outputs.debug << "Player: #{state.player}"
  # args.outputs.debug << "Camera: #{state.camera}"
end

class PlayScene
  def tick(args)
    args.state.world ||= {
      height: 50_000,
      width: 50_000,
    }

    args.state.tile_size ||= 32
    args.state.tiles ||= generate_tiles(args)

    args.state.player ||= {
      x: 0,
      y: 0,
      w: 24,
      h: 24,
      dy: 0,
      dx: 0,
      direction: "right",
      path: "sprites/1-bit-platformer/0280.png"
    }

    state = args.state
    player = state.player

    if args.inputs.directional_angle
      dx = args.inputs.directional_angle.vector_x * 1.4
      dy = args.inputs.directional_angle.vector_y * 1.4

      # vector_* comes out to like 0.00000003 or some floating point bullshit above 0. This accounts for that.
      dx.abs < 1 ? player.dx = 0 : player.dx = dx
      dy.abs < 1 ? player.dy = 0 : player.dy = dy

      player.dx += dx
      player.dy += dy

      # make sure the person is actually moving right / left.
      if dy.abs < 1
        if player.dx > 0
          player.direction = "right"
        elsif player.dx < 0
          player.direction = "left"
        end
      end
    end

    if args.inputs.keyboard.key_down.equal_sign || args.inputs.keyboard.key_down.plus
      state.camera.target_scale += 0.25
    elsif args.inputs.keyboard.key_down.minus
      state.camera.target_scale -= 0.25
      state.camera.target_scale = 0.25 if state.camera.target_scale < 0.25
    elsif args.inputs.keyboard.zero
      state.camera.target_scale = 1
    end

    calc_camera(args)
    calc_movement(args)
    args.outputs.background_color = [255, 255, 255]
    args.outputs[:scene].transient!
    args.outputs[:scene].w = Camera::VIEWPORT_SIZE
    args.outputs[:scene].h = Camera::VIEWPORT_SIZE

    tiles_to_render = Camera.find_all_intersect_viewport(args.state.camera, args.state.tiles)
    outputs[:scene].sprites << tiles_to_render.map do |tile|
      Camera.to_screen_space(args.state.camera, tile)
    end

    args.outputs.debug << "camera_x: #{state.camera.x}"
    args.outputs.debug << "camera_y: #{state.camera.y}"
    args.outputs.debug << "Tiles on screen: #{tiles_to_render.length}"
    args.outputs.debug << "Total tiles: #{args.state.tiles.length * args.state.tiles[0].length}"

    args.outputs[:scene].sprites << [
      args.state.tiles_to_render,
      player_prefab(args)
    ]

    args.outputs.sprites << { **Camera.viewport, path: :scene }
  end

  def generate_tiles(args)
    tile_size = args.state.tile_size

    columns = ((args.state.world.width) / tile_size).round
    rows = ((args.state.world.height) / tile_size).round

    tiles = []


    columns.times do |x|
      tiles[x] = []
      rows.times do |y|
        tile_x = ((x + x_offset) * args.state.tile_size) * camera.scale - camera.x * camera.scale + Camera::VIEWPORT_SIZE_HALF
        tile_y = ((y + y_offset) * args.state.tile_size) * camera.scale - camera.y * camera.scale + Camera::VIEWPORT_SIZE_HALF
        tile_size = args.state.tile_size * camera.scale

        type = :plain

        # 1% chance to be a rock.
        if random_num > 99
          type = :rock
        end

        # 1% chance to be a tree.
        if random_num > 98 && random_num <= 99
          type = :tree
        end

        path = args.state.symbol_to_path[type]

        tiles[x][y] = {
          x: tile_x,
          y: tile_y,
          w: tile_size,
          h: tile_size,
          path: path,
          type: type,
        }
      end
    end

    tiles
  end

  def calc_camera(args)
    state = args.state
    state.camera ||= {
      x: 0,
      y: 0,
      target_x: 0,
      target_y: 0,
      target_scale: 2,
      scale: 1,
    }

    camera = state.camera

    ease = 0.1
    state.camera.scale += (state.camera.target_scale - state.camera.scale) * ease

    state.camera.target_x = state.player.x
    state.camera.target_y = state.player.y

    # Makes sure we dont show empty space.
    min_x = [(Camera.scaled_screen_width(camera.scale) / 2) - (state.player.w / 2), state.camera.target_x].max
    min_y = [(Camera.scaled_screen_height(camera.scale) / 2) - (state.player.h / 2), state.camera.target_y].max
    state.camera.target_x = min_x
    state.camera.target_y = min_y

    state.camera.target_x = state.camera.target_x.clamp(state.camera.target_x, state.world.width - Camera.scaled_screen_width(camera.scale) / 2)
    state.camera.target_y = state.camera.target_y.clamp(state.camera.target_y, state.world.height - Camera.scaled_screen_height(camera.scale) / 2)

    state.camera.x += (state.camera.target_x - state.camera.x)
    state.camera.y += (state.camera.target_y - state.camera.y)
  end

  def calc_movement(args)
    player = args.state.player
    player.x += player.dx
    player.y += player.dy

    player.dx *= 0.8
    if player.dx.abs < 0.1
      player.dx = 0
    end

    player.dy *= 0.8
    if player.dy.abs < 0.1
      player.dy = 0
    end

    player.x = player.x.clamp(0, args.state.world.width - ((player.w * 3) / 2))
    player.y = player.y.clamp(0, args.state.world.height - ((player.h * 3) / 2))
  end

  def player_prefab(args)
    path = "sprites/1-bit-platformer/0280.png"

    prefab = Camera.to_screen_space args.state.camera, (args.state.player.merge path: path)

    frame_index = 0.frame_index 3, 5, true

    if args.state.player.direction == "right"
      prefab.merge! path: "sprites/1-bit-platformer/028#{frame_index + 1}.png"
    elsif args.state.player.direction == "left"
      prefab.merge! path: "sprites/1-bit-platformer/028#{frame_index + 1}.png", flip_horizontally: true
    end

    prefab
  end
end


$gtk.reset

You might say “Wow thats a lot of code, what the hell is your point Konnor.” And that is a reasonable response.

So instead, lets focus on the thing thats causing major slowdowns for our rendering of our viewport.

This code right here:

Ruby
class PlayScene
  def tick(args)
    # ...
    tiles_to_render = Camera.find_all_intersect_viewport(args.state.camera, args.state.tiles)
    outputs[:scene].sprites << tiles_to_render.map do |tile|
      Camera.to_screen_space(state.camera, tile)
    end
    # ...
  end
end

Let’s talk about why this is slow.

Our args.state.world is 50_000 x 50_000 (thats number of pixels, so divide this number by 32 to get number of tiles) so we have roughly 2.5 million tiles we’re holding in memory.

Every tick, so 60 times a second, we are iterating 2.5 million times over an array when we call Camera.find_all_intersect_viewport. Thats a lot of unnecessary iterations. My first time with this code, I could only render a world roughly 1000 x 1000 (roughly 31,250 tiles) before I would start dropping frames.

To clean that up, I could “approximate” the viewport size, and since everything is laid out in [x][y] we can just make it so we only ever iterate on tiles we know are in the viewport.

So we’ll take this:

Ruby
tiles_to_render = Camera.find_all_intersect_viewport(args.state.camera, args.state.tiles)

outputs[:scene].sprites << tiles_to_render.map do |tile|
  Camera.to_screen_space(state.camera, tile)
end

and turn it into this:

Ruby
tiles_to_render = []

# Calculate the min_x (the lowest possible x value of the viewport) in pixels
min_x = [state.camera.x - (Camera.scaled_screen_width(state.camera.scale) / 2) - (state.player.w / 2), 0].max

# Calculate the min_y (the lowest possible value of the viewport) in pixels
min_y = [state.camera.y - (Camera.scaled_screen_height(state.camera.scale) / 2) - (state.player.h / 2), 0].max

# Calculate the lowest possible x tile we can render by converting pixels to tiles
x_offset = (min_x / args.state.tile_size).floor

# Calculate the lowest possible y tile we can render by converting pixels to tiles
y_offset = (min_y / args.state.tile_size).floor

# How many extra tiles to render outside of the viewport to prevent showing blank tiles while a player is moving.
buffer_area = args.state.tile_size * 6


# Calculate the highest possible x tile we can render
max_x_tiles = ((Camera.scaled_screen_width(state.camera.scale)  + buffer_area) / args.state.tile_size).ceil

# Calculate the highest possible y tile we can render
max_y_tiles = ((Camera.scaled_screen_height(state.camera.scale) + buffer_area) / args.state.tile_size).ceil

# Add only the tiles we need to render preventing unnecessary work
index = -1
max_x_tiles.times do |x|
  max_y_tiles.times do |y|
    ary = args.state.tiles[x + x_offset]

    # We know args.state.tiles[x] is either an array of hashes (tiles) or its nil because its out of bounds.
    next if ary.nil? || ary.length <= 0

    tile = ary[y + y_offset]

    # We know args.state.tiles[x][y] is either a hash (tile) or its nil because it doesn't exist.
    next if tile.nil?

    index += 1

    tiles_to_render << Camera.to_screen_space(args.state.camera, tile)
  end
end

args.outputs[:scene].sprites << [
  tiles_to_render,
  player_prefab(args)
]

This is quite a bit of extra code we didn’t have before. But I promise, its worth it in this case! The basic gist of the above is that we find where the player / camera is, and we find the highest x/y values and the lowest possible x/y values so we only ever iterate over the tiles we know are in the viewport of the player, and then we calculate the screen space of the tiles and push it into tiles_to_render.

Now the above code is pretty good, and I’m pretty sure I got somewhere around a 10_000 x 10_000 world before I started to drop frames, which is a pretty big improvement!

But! We can still do better.

Something I realized in our generate_tiles code is that we’re eating up a lot of memory for no reason by creating a hash for every possible tile holding x, y, h, w, and path. Realistically, we don’t need to know that stuff until the tile is in the viewport. All we care about is the “type” of tile. Checkout what I mean in lines 31-38.

We’re holding 2.5million hashes we don’t need! Each Hash is a unique object in Ruby. I had a brain blast moment and realized we could reduce our memory consumption by not storing the hash, and instead, just storing the :type of the tile, since that’s the only information we really need to know. Like so:

Ruby
def generate_tiles(args)
  tile_size = args.state.tile_size

  columns = ((args.state.world.width) / tile_size).round
  rows = ((args.state.world.height) / tile_size).round

  tiles = {}

  columns.times do |x|
    tiles[x] = {}
    rows.times do |y|
      type = :plain

      # 1% chance to be a rock.
      if random_num > 99
        type = :rock
      end

      # 1% chance to be a tree.
      if random_num > 98 && random_num <= 99
        type = :tree
      end

      tiles[x][y] = type
    end
  end

  tiles
end

I also converted the tiles from an array of arrays to a hash of hashes. I don’t know this makes any difference, but I wanted to so I did.

Our tile structure now looks roughly like this:

Ruby
tiles = {
  0: {
    0: :plain,
    1: :rock,
    2: :plain,
  },
  1: {
    0: :rock,
    1: :tree,
    2: :tree,
  }
}

tiles[0][0] # => :plain
tiles[0][1] # => :rock
tiles[1][1] # => :tree

For our purposes, it still roughly acts like an array where we can just call tiles[x][y]. Moving on, the reason why the above code is important is that “symbols” in Ruby, the :plain, :rock, :tree, all only create a single “object allocation”. You can check this yourself by calling :plain.object_id multiple times and see you get the same id everytime. This means they “share” a reference in memory. So now instead of allocating 2.5million Objects in Ruby, we’re allocating 3. One for each symbol. Yes, we still have to allocate memory for each “y” hash, but that is besides the point, the old code was doing that too. We could technically do something like:

Ruby
tiles["#{x},#{y}"]

And create a single flat hash, I really don’t know if that would make a difference by doing string look ups versus using 50,000 hashes. Maybe this is a future optimization I could make. 🤔

Back to the code!

Our new massive hash of hashes containing the tile type won’t render. So now we need to adjust the code a little bit.

Ruby
tiles_to_render = []

# Calculate the min_x (the lowest possible x value of the viewport) in pixels
min_x = [state.camera.x - (Camera.scaled_screen_width(state.camera.scale) / 2) - (state.player.w / 2), 0].max

# Calculate the min_y (the lowest possible value of the viewport) in pixels
min_y = [state.camera.y - (Camera.scaled_screen_height(state.camera.scale) / 2) - (state.player.h / 2), 0].max

# Calculate the lowest possible x tile we can render by converting pixels to tiles
x_offset = (min_x / args.state.tile_size).floor

# Calculate the lowest possible y tile we can render by converting pixels to tiles
y_offset = (min_y / args.state.tile_size).floor

# How many extra tiles to render outside of the viewport to prevent showing blank tiles while a player is moving.
buffer_area = args.state.tile_size * 6


# Calculate the highest possible x tile we can render
max_x_tiles = ((Camera.scaled_screen_width(state.camera.scale)  + buffer_area) / args.state.tile_size).ceil

# Calculate the highest possible y tile we can render
max_y_tiles = ((Camera.scaled_screen_height(state.camera.scale) + buffer_area) / args.state.tile_size).ceil

# Add only the tiles we need to render preventing unnecessary work
index = -1
max_x_tiles.times do |x|
  max_y_tiles.times do |y|
    ary = args.state.tiles[x + x_offset]

    # We know args.state.tiles[x] is either an array of hashes (tiles) or its nil because its out of bounds.
    next if ary.nil? || ary.length <= 0

    tile = ary[y + y_offset]

+    # We know args.state.tiles[x][y] is either a :symbol (tile) or its nil because it doesn't exist.
-    # We know args.state.tiles[x][y] is either a hash (tile) or its nil because it doesn't exist.
    next if tile.nil?

    index += 1

+   tile_x = (x + x_offset) * args.state.tile_size
+   tile_y = (y + y_offset) * args.state.tile_size

+   tile_hash = {
+     x: tile_x,
+     y: tile_y,
+     w: args.state.tile_size,
+     h: args.state.tile_size,
+     path: args.state.symbol_to_path(tile)
+   }

    tiles_to_render << Camera.to_screen_space(args.state.camera, tile_hash)
  end
end

args.outputs[:scene].sprites << [
  tiles_to_render,
  player_prefab(args)
]

This is looking pretty good! But would you believe me if I told you we could still improve this?!

Every tick, we’re regenerating tiles_to_render and generating all the hashes and calculating screen space. What if I told you we could store tiles_to_render in args.state and persist these hashes and modify them in place across frames reducing number of object allocations and the work that the garbage collector needs to do! So let’s do that!

Ruby
+ args.state.tiles_to_render ||= []
- tiles_to_render = []

min_x = [state.camera.x - (Camera.scaled_screen_width(state.camera.scale) / 2) - (state.player.w / 2), 0].max
min_y = [state.camera.y - (Camera.scaled_screen_height(state.camera.scale) / 2) - (state.player.h / 2), 0].max

x_offset = (min_x / args.state.tile_size).floor
y_offset = (min_y / args.state.tile_size).floor

# How many extra tiles to render outside of the viewport.
buffer_area = args.state.tile_size * 6

max_x_tiles = ((Camera.scaled_screen_width(state.camera.scale)  + buffer_area) / args.state.tile_size).ceil
max_y_tiles = ((Camera.scaled_screen_height(state.camera.scale) + buffer_area) / args.state.tile_size).ceil

+ # if camera scale changes, remove extra tiles
+ if max_x_tiles * max_y_tiles < args.state.tiles_to_render.length
+  args.state.tiles_to_render.slice!(0, (max_x_tiles * max_y_tiles).ceil)
+ end

index = -1
max_x_tiles.times do |x|
  max_y_tiles.times do |y|
    ary = args.state.tiles[x + x_offset]

    next if ary.nil? || ary.length <= 0

    tile = ary[y + y_offset]

    next if tile.nil?

    index += 1

-   tile_x = (x + x_offset) * args.state.tile_size
-   tile_y = (y + y_offset) * args.state.tile_size

-   tile_hash = {
-     x: tile_x,
-     y: tile_y,
-     w: args.state.tile_size,
-     h: args.state.tile_size,
-     path: args.state.symbol_to_path(tile)
-   }

+   camera = args.state.camera
+   # The below replaces Camera.to_screen_space. The reason being is we can calculate the `tile_x` here and not need to create a new hash like `to_screen_space` does. I dont recommend this for normal code, but this is a "hot path"
+   tile_x = ((x + x_offset) * args.state.tile_size) * camera.scale - camera.x * camera.scale + Camera::VIEWPORT_SIZE_HALF
+   tile_y = ((y + y_offset) * args.state.tile_size) * camera.scale - camera.y * camera.scale + Camera::VIEWPORT_SIZE_HALF
+   tile_size = args.state.tile_size * camera.scale
+   path = args.state.symbol_to_path[tile]

+   # If the tile doesnt exist in rednered terrain, make it.
+   args.state.tiles_to_render[index] ||= {
+     x: tile_x,
+     y: tile_y,
+     w: tile_size,
+     h: tile_size,
+     path: path
+   }
+
+   tile = args.state.tiles_to_render[index]

+   tile[:x] = tile_x
+   tile[:y] = tile_y
+   tile[:w] = tile_size
+   tile[:h] = tile_size
+   tile[:path] = path
  end
end

With the above, now we get to hold only the number of tiles on screen in memory as a hash. We modify the hash in place every tick, and we adjust the number of tiles we’re rendering if the camera scale changes from zooming out with this line of code here:

Ruby
# if camera scale changes, remove extra tiles
if max_x_tiles * max_y_tiles < args.state.tiles_to_render.length
  args.state.tiles_to_render.slice!(0, (max_x_tiles * max_y_tiles).ceil)
end

This is pretty solid. There’s a couple other optimizations we could make with tiles but I dont know how much more performance we can get out of this. We’re already pretty lazy in the amount of work we’re doing. But, theoretically we could convert tiles from hashes to classes, and then use a draw_override as explained here:

https://docs.dragonruby.org/#/samples/performance?id=static-sprites-as-classes-with-custom-drawing-mainrb

But, I didn’t feel this was necessary, but I did convert tile hashes to classes as I do remember Amir (creator of DragonRuby) mentioned this was faster than hashes due to method accessors being faster than hash accessors.

Anyway, this was a lot of code, I don’t know how much of it made sense, but I wanted to walk you through my refactoring process for hot paths in DragonRuby and how I was able to take code that was only about to handle about 31_250 tiles and turn it into code able to handle ~2_500_000 (2.5million) tiles in memory.

Here’s the final code for the curious:

Ruby
class Camera
  SCREEN_WIDTH = 1280
  SCREEN_HEIGHT = 720

  VIEWPORT_SIZE = 1500
  VIEWPORT_SIZE_HALF = VIEWPORT_SIZE / 2

  OFFSET_X = (SCREEN_WIDTH - VIEWPORT_SIZE) / 2
  OFFSET_Y = (SCREEN_HEIGHT - VIEWPORT_SIZE) / 2

  class << self
    def to_world_space camera, rect
      x = (rect.x - VIEWPORT_SIZE_HALF + camera.x * camera.scale - OFFSET_X) / camera.scale
      y = (rect.y - VIEWPORT_SIZE_HALF + camera.y * camera.scale - OFFSET_Y) / camera.scale
      w = rect.w / camera.scale
      h = rect.h / camera.scale
      rect.merge x: x, y: y, w: w, h: h
    end

    def to_screen_space camera, rect
      x = rect.x * camera.scale - camera.x * camera.scale + VIEWPORT_SIZE_HALF
      y = rect.y * camera.scale - camera.y * camera.scale + VIEWPORT_SIZE_HALF
      w = rect.w * camera.scale
      h = rect.h * camera.scale
      rect.merge x: x, y: y, w: w, h: h
    end

    def viewport
      {
        x: OFFSET_X,
        y: OFFSET_Y,
        w: 1500,
        h: 1500
      }
    end

    def viewport_world camera
      to_world_space camera, viewport
    end

    def find_all_intersect_viewport camera, os
      Geometry.find_all_intersect_rect viewport_world(camera), os
    end

    def scaled_screen_height scale
      SCREEN_HEIGHT / scale
    end

    def scaled_screen_width scale
      SCREEN_WIDTH / scale
    end
  end
end

class Tile
  attr_sprite

  def initialize(
    x:,
    y:,
    w:,
    h:,
    path:
  )
    @x = x
    @y = y
    @w = w
    @h = h
    @path = path
  end
end


def tick(args)
  args.state.symbol_to_path ||= {
    plain: "sprites/1-bit-platformer/0108.png",
    rock: "sprites/rock-1.png",
    tree: "sprites/tree-1.png"
  }
  args.state.play_scene ||= PlayScene.new
  args.state.current_scene ||= args.state.play_scene
  current_scene = args.state.current_scene

  args.state.current_scene.tick(args)

  # make sure that the current_scene flag wasn't set mid tick
  if args.state.current_scene != current_scene
    raise "Scene was changed incorrectly. Set args.state.next_scene to change scenes."
  end

  # if next scene was set/requested, then transition the current scene to the next scene
  if args.state.next_scene
    # cleanup any state.
    args.state.current_scene.cleanup_state(args)

    # set current scene for next tick.
    args.state.current_scene = args.state.next_scene
    args.state.next_scene = nil
  end

  args.outputs.debug << "Framerate: #{$gtk.current_framerate}"
  # args.outputs.debug << "Player: #{state.player}"
  # args.outputs.debug << "Camera: #{state.camera}"
end

class PlayScene
  def tick(args)
    args.state.world ||= {
      height: 50_000,
      width: 50_000,
    }

    args.state.tile_size ||= 32
    args.state.tiles ||= generate_tiles(args)

    args.state.player ||= {
      x: 0,
      y: 0,
      w: 24,
      h: 24,
      dy: 0,
      dx: 0,
      direction: "right",
      path: "sprites/1-bit-platformer/0280.png"
    }

    state = args.state
    player = state.player

    if args.inputs.directional_angle
      dx = args.inputs.directional_angle.vector_x * 1.4
      dy = args.inputs.directional_angle.vector_y * 1.4

      # vector_* comes out to like 0.00000003 or some floating point bullshit above 0. This accounts for that.
      dx.abs < 1 ? player.dx = 0 : player.dx = dx
      dy.abs < 1 ? player.dy = 0 : player.dy = dy

      player.dx += dx
      player.dy += dy

      # make sure the person is actually moving right / left.
      if dy.abs < 1
        if player.dx > 0
          player.direction = "right"
        elsif player.dx < 0
          player.direction = "left"
        end
      end
    end

    if args.inputs.keyboard.key_down.equal_sign || args.inputs.keyboard.key_down.plus
      state.camera.target_scale += 0.25
    elsif args.inputs.keyboard.key_down.minus
      state.camera.target_scale -= 0.25
      state.camera.target_scale = 0.25 if state.camera.target_scale < 0.25
    elsif args.inputs.keyboard.zero
      state.camera.target_scale = 1
    end

    calc_camera(args)
    calc_movement(args)
    args.outputs.background_color = [255, 255, 255]
    args.outputs[:scene].transient!
    args.outputs[:scene].w = Camera::VIEWPORT_SIZE
    args.outputs[:scene].h = Camera::VIEWPORT_SIZE

    args.state.tiles_to_render ||= []

    min_x = [state.camera.x - (Camera.scaled_screen_width(state.camera.scale) / 2) - (state.player.w / 2), 0].max
    min_y = [state.camera.y - (Camera.scaled_screen_height(state.camera.scale) / 2) - (state.player.h / 2), 0].max

    x_offset = (min_x / args.state.tile_size).floor
    y_offset = (min_y / args.state.tile_size).floor

    # How many extra tiles to render outside of the viewport.
    buffer_area = args.state.tile_size * 6

    max_x_tiles = ((Camera.scaled_screen_width(state.camera.scale)  + buffer_area) / args.state.tile_size).ceil
    max_y_tiles = ((Camera.scaled_screen_height(state.camera.scale) + buffer_area) / args.state.tile_size).ceil

    # if camera scale changes, remove extra tiles
    if max_x_tiles * max_y_tiles < args.state.tiles_to_render.length
      args.state.tiles_to_render.slice!(0, (max_x_tiles * max_y_tiles).ceil)
    end

    index = -1
    max_x_tiles.times do |x|
      max_y_tiles.times do |y|
        ary = args.state.tiles[x + x_offset]

        next if ary.nil? || ary.length <= 0

        tile = ary[y + y_offset]

        next if tile.nil?

        index += 1

        camera = args.state.camera
        tile_x = ((x + x_offset) * args.state.tile_size) * camera.scale - camera.x * camera.scale + Camera::VIEWPORT_SIZE_HALF
        tile_y = ((y + y_offset) * args.state.tile_size) * camera.scale - camera.y * camera.scale + Camera::VIEWPORT_SIZE_HALF
        tile_size = args.state.tile_size * camera.scale

        path = args.state.symbol_to_path[tile]

        # If the tile doesnt exist in rednered terrain, make it.
        args.state.tiles_to_render[index] ||= Tile.new(
          x: tile_x,
          y: tile_y,
          w: tile_size,
          h: tile_size,
          path: path
        )

        tile = args.state.tiles_to_render[index]

        tile.x = tile_x
        tile.y = tile_y
        tile.w = tile_size
        tile.h = tile_size
        tile.path = path
      end
    end

    args.outputs.debug << "camera_x: #{state.camera.x}"
    args.outputs.debug << "camera_y: #{state.camera.y}"
    args.outputs.debug << "min_x: #{min_x}"
    args.outputs.debug << "min_y: #{min_y}"
    args.outputs.debug << "Tiles on screen: #{args.state.tiles_to_render.length}"
    args.outputs.debug << "Total tiles: #{args.state.tiles.length * args.state.tiles[0].length}"

    args.outputs[:scene].sprites << [
      args.state.tiles_to_render,
      player_prefab(args)
    ]

    args.outputs.sprites << { **Camera.viewport, path: :scene }
  end

  def generate_tiles(args)
    tile_size = args.state.tile_size

    columns = ((args.state.world.width) / tile_size).round
    rows = ((args.state.world.height) / tile_size).round

    tiles = {}

    columns.times do |x|
      tiles[x] = {}
      rows.times do |y|
        random_num = rand * 100

        type = :plain

        # 1% chance to be a rock.
        if random_num > 99
          type = :rock
        end

        # 1% chance to be a tree.
        if random_num > 98 && random_num <= 99
          type = :tree
        end

        tiles[x][y] = type
      end
    end

    tiles
  end

  def calc_camera(args)
    state = args.state
    state.camera ||= {
      x: 0,
      y: 0,
      target_x: 0,
      target_y: 0,
      target_scale: 2,
      scale: 1,
    }

    camera = state.camera

    ease = 0.1
    state.camera.scale += (state.camera.target_scale - state.camera.scale) * ease

    state.camera.target_x = state.player.x
    state.camera.target_y = state.player.y

    # Makes sure we dont show empty space.
    min_x = [(Camera.scaled_screen_width(camera.scale) / 2) - (state.player.w / 2), state.camera.target_x].max
    min_y = [(Camera.scaled_screen_height(camera.scale) / 2) - (state.player.h / 2), state.camera.target_y].max
    state.camera.target_x = min_x
    state.camera.target_y = min_y

    state.camera.target_x = state.camera.target_x.clamp(state.camera.target_x, state.world.width - Camera.scaled_screen_width(camera.scale) / 2)
    state.camera.target_y = state.camera.target_y.clamp(state.camera.target_y, state.world.height - Camera.scaled_screen_height(camera.scale) / 2)

    state.camera.x += (state.camera.target_x - state.camera.x)
    state.camera.y += (state.camera.target_y - state.camera.y)
  end

  def calc_movement(args)
    player = args.state.player
    player.x += player.dx
    player.y += player.dy

    player.dx *= 0.8
    if player.dx.abs < 0.1
      player.dx = 0
    end

    player.dy *= 0.8
    if player.dy.abs < 0.1
      player.dy = 0
    end

    player.x = player.x.clamp(0, args.state.world.width - ((player.w * 3) / 2))
    player.y = player.y.clamp(0, args.state.world.height - ((player.h * 3) / 2))
  end

  def player_prefab(args)
    path = "sprites/1-bit-platformer/0280.png"

    prefab = Camera.to_screen_space args.state.camera, (args.state.player.merge path: path)

    frame_index = 0.frame_index 3, 5, true

    if args.state.player.direction == "right"
      prefab.merge! path: "sprites/1-bit-platformer/028#{frame_index + 1}.png"
    elsif args.state.player.direction == "left"
      prefab.merge! path: "sprites/1-bit-platformer/028#{frame_index + 1}.png", flip_horizontally: true
    end

    prefab
  end
end


$gtk.reset

Source code is also available here:

https://github.com/KonnorRogers/dragonruby-examples/tree/main/examples/largemap

Do note, all of the above numbers were recorded and eyeballed using Macbook M1 14” Pro with 16GB of RAM. Results may vary on different devices. I didn’t run benchmarks, I just have a rough idea of how Ruby memory and performance works. Things like reducing object allocations, modify objects in place rather than create new objects etc.

Here’s a video for proof. Check the top left for debug output for number of tiles in memory vs number of tiles being rendered.

https://youtu.be/zJ8JmbR5pE8