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
Full original source code. *Warning* its about 300 lines, and most of it isn't important.
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:
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:
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:
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:
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:
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:
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.
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!
+ 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:
# 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:
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:
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.