This skill covers advanced rendering patterns in DragonRuby GTK. For basic sprite/label/solid output see the main dragonruby skill.
Render Targets (Off-screen Buffers)
Render targets let you draw into a named virtual canvas, then use it as a sprite. They are very powerful for caching, effects, and UI composition.
# Draw into target each tick (transient! clears it each frame):
args.outputs[:hud].transient!
args.outputs[:hud].width = 400
args.outputs[:hud].height = 200
args.outputs[:hud].background_color = [0, 0, 0, 0] # transparent bg
args.outputs[:hud].sprites << { x: 0, y: 0, w: 32, h: 32, path: 'sprites/icon.png' }
args.outputs[:hud].labels << { x: 200, y: 100, text: "HP: 3", alignment_enum: 1 }
# Composite into the main scene:
args.outputs.sprites << { x: 0, y: 520, w: 400, h: 200, path: :hud }
Cached render target (draw once, reuse)
# Build the target only on tick 0 (or when content changes):
if Kernel.tick_count == 0
args.outputs[:map_tiles].width = 2560
args.outputs[:map_tiles].height = 1440
world_tiles.each { |t| args.outputs[:map_tiles].sprites << t }
end
# Render a viewport portion of the cached target each tick:
args.outputs.sprites << {
x: 0, y: 0, w: 1280, h: 720,
path: :map_tiles,
source_x: cam.x, source_y: cam.y,
source_w: 1280, source_h: 720
}
Checking target readiness
return if args.outputs.render_targets.queued?(:my_target)
return unless args.outputs.render_targets.ready?(:my_target)
Blendmodes & Clipping
# Blendmode 0 = ignore alpha (overwrite). Use for hollow borders:
args.outputs[:border_target].primitives << [
{ x: 0, y: 0, w: w, h: h, path: :solid, r: 255, g: 255, b: 255 },
{ x: t, y: t, w: w - t*2, h: h - t*2, path: :empty, blendmode: 0 }
]
Clipping (viewport mask)
args.outputs[:clipped].width = clip_w
args.outputs[:clipped].height = clip_h
args.outputs[:clipped].sprites << all_world_sprites
args.outputs.sprites << {
x: clip_x, y: clip_y, w: clip_w, h: clip_h,
path: :clipped,
source_x: clip_x, source_y: clip_y,
source_w: clip_w, source_h: clip_h
}
Camera Systems
Simple follow camera
# Camera state
args.state.camera ||= { x: 0, y: 0, scale: 1.0 }
# Smooth follow
args.state.camera.x = args.state.camera.x.lerp(player.x - 640, 0.08)
args.state.camera.y = args.state.camera.y.lerp(player.y - 360, 0.08)
# Apply camera to all world entities:
def world_to_screen(camera, rect)
{ **rect,
x: rect.x - camera.x,
y: rect.y - camera.y }
end
Scalable camera (zoom + pan)
VIEWPORT_W = 1280
VIEWPORT_H = 720
def to_screen_space(camera, rect)
{
**rect,
x: rect[:x] * camera[:scale] - camera[:x] * camera[:scale] + VIEWPORT_W / 2,
y: rect[:y] * camera[:scale] - camera[:y] * camera[:scale] + VIEWPORT_H / 2,
w: rect[:w] * camera[:scale],
h: rect[:h] * camera[:scale]
}
end
def to_world_space(camera, rect)
{
x: (rect[:x] - VIEWPORT_W / 2 + camera[:x] * camera[:scale]) / camera[:scale],
y: (rect[:y] - VIEWPORT_H / 2 + camera[:y] * camera[:scale]) / camera[:scale]
}
end
# Usage:
args.outputs.sprites << world_entities.map { |e| to_screen_space(args.state.camera, e) }
Viewport culling (only render visible entities)
viewport = {
x: camera.x - 640 / camera.scale,
y: camera.y - 360 / camera.scale,
w: 1280 / camera.scale,
h: 720 / camera.scale
}
visible = Geometry.find_all_intersect_rect(viewport, args.state.world_entities)
args.outputs.sprites << visible.map { |e| to_screen_space(camera, e) }
Camera shake (trauma system)
args.state.trauma ||= 0.0
args.state.trauma = (args.state.trauma + 0.3).clamp(0, 1) # on hit
# Apply shake:
shake = 20.0 * args.state.trauma ** 2
args.state.camera.x_offset = shake.randomize(:ratio, :sign)
args.state.camera.y_offset = shake.randomize(:ratio, :sign)
args.state.trauma *= 0.93 # decay
Z-targeting / orbit camera
args.state.camera.angle ||= 0
distance = 200
target_x = entity.x + args.state.camera.angle.vector_x * distance
target_y = entity.y + args.state.camera.angle.vector_y * distance
args.state.camera.angle += 1 # orbit
Render target + camera (full pipeline)
# Draw world to a render target using camera offset:
args.outputs[:scene].transient!
args.outputs[:scene].width = 1280
args.outputs[:scene].height = 720
args.outputs[:scene].sprites << visible_tiles.map { |t|
t.merge(x: t.x - cam.x, y: t.y - cam.y)
}
# Blit scene + apply shake:
args.outputs.sprites << {
x: cam.x_offset.to_i, y: cam.y_offset.to_i,
w: 1280, h: 720, path: :scene
}
Pixel Arrays
Direct pixel-level manipulation for dynamic textures:
# Create a pixel array (ABGR 32-bit integers):
w = 64; h = 64
args.pixel_array(:canvas).width = w
args.pixel_array(:canvas).height = h
# Fill solid black:
args.pixel_array(:canvas).pixels.fill(0xFF000000, 0, w * h)
# Draw a horizontal green line at row y:
y = 32
args.pixel_array(:canvas).pixels.fill(0xFF00FF00, y * w, w)
# Set individual pixel (x, y):
def set_pixel(px_array, w, x, y, abgr)
px_array.pixels[y * w + x] = abgr
end
# ABGR hex format: 0xAABBGGRR
RED = 0xFF0000FF
GREEN = 0xFF00FF00
BLUE = 0xFFFF0000
# Animate pixel-by-pixel scanner:
args.state.scan_pos = (args.state.scan_pos || 0 + 1) % h
args.pixel_array(:scanner).width = w
args.pixel_array(:scanner).height = h
args.pixel_array(:scanner).pixels.fill(0xFF000000, 0, w * h)
args.pixel_array(:scanner).pixels.fill(0xFF00FF00, args.state.scan_pos * w, w)
# Use pixel array as a sprite:
args.outputs.sprites << { x: 100, y: 100, w: w, h: h, path: :canvas }
Load pixels from a file
px = GTK.get_pixels('sprites/map.png') # => { w:, h:, pixels: [] }
args.pixel_array(:map).width = px.w
args.pixel_array(:map).height = px.h
args.pixel_array(:map).pixels = px.pixels
HD & High-DPI Rendering
Automatic HD sprite selection
DragonRuby automatically picks the right resolution file by naming convention:
- 720p:
player.png - 1080p:
player@125.png - 1440p:
player@200.png - 4K:
player@300.png
No code changes needed — just provide the additional files.
# Query current rendering scale at runtime:
args.grid.texture_scale_enum # integer scale enum
args.grid.native_scale # float scale multiplier
Allscreen properties (ultrawide/notch support)
args.grid.allscreen_w # full renderable width (beyond safe area)
args.grid.allscreen_h
args.grid.allscreen_offset_x # offset from left to safe area
args.grid.left # safe area left edge
args.grid.right # safe area right edge
# Background that fills to screen edges:
args.outputs.sprites << {
x: args.grid.allscreen_left,
y: args.grid.allscreen_bottom,
w: args.grid.allscreen_w,
h: args.grid.allscreen_h,
path: 'sprites/bg.png',
source_x: 2000 - args.grid.allscreen_w / 2,
source_y: 2000 - args.grid.allscreen_h / 2,
source_w: args.grid.allscreen_w,
source_h: args.grid.allscreen_h
}
Low-Resolution (Pixel Art) Display
Scale a tiny virtual canvas up to fill the screen — classic for pixel art games:
GAME_W = 84; GAME_H = 48 # virtual resolution (e.g. Nokia 3310)
SCREEN_W = 1280; SCREEN_H = 720
ZOOM = [SCREEN_W / GAME_W, SCREEN_H / GAME_H].min
OFFSET_X = (SCREEN_W - GAME_W * ZOOM) / 2
OFFSET_Y = (SCREEN_H - GAME_H * ZOOM) / 2
def tick(args)
# Draw at native resolution into target:
args.outputs[:game].transient!
args.outputs[:game].width = GAME_W
args.outputs[:game].height = GAME_H
args.outputs[:game].background_color = [199, 240, 216]
# ... all game rendering goes here at GAME_W x GAME_H coords
# Scale up to screen:
args.outputs.sprites << {
x: OFFSET_X, y: OFFSET_Y,
w: GAME_W * ZOOM, h: GAME_H * ZOOM,
path: :game
}
end
# Translate mouse input from screen space back to game space:
def game_mouse(args)
{ x: (args.inputs.mouse.x - OFFSET_X).idiv(ZOOM),
y: (args.inputs.mouse.y - OFFSET_Y).idiv(ZOOM) }
end
Thick Lines
DragonRuby only draws 1px lines natively. Emulate thick lines with a rotated solid:
def thick_line(x:, y:, x2:, y2:, thickness: 3, r: 255, g: 255, b: 255, a: 255)
dx = x2 - x; dy = y2 - y
length = Math.sqrt(dx*dx + dy*dy)
angle = Math.atan2(dy, dx).to_degrees
{
x: x, y: y,
w: length, h: thickness,
angle: angle, angle_anchor_x: 0, angle_anchor_y: 0.5,
path: :solid,
r: r, g: g, b: b, a: a
}
end
args.outputs.sprites << thick_line(x: 100, y: 100, x2: 400, y2: 300, thickness: 4)
Repeating / Tiled Textures
def tiled_background(args, path, dest_x:, dest_y:, dest_w:, dest_h:)
sw, sh = args.gtk.calcspritebox(path)
rt_name = :"tile_#{path}"
if Kernel.tick_count == 0
args.outputs[rt_name].width = dest_w
args.outputs[rt_name].height = dest_h
cols = (dest_w.fdiv(sw)).ceil + 1
rows = (dest_h.fdiv(sh)).ceil + 1
args.outputs[rt_name].sprites << rows.map { |r|
cols.map { |c|
{ x: c * sw, y: dest_h - (r + 1) * sh, w: sw, h: sh, path: path }
}
}
end
{ x: dest_x, y: dest_y, w: dest_w, h: dest_h, path: rt_name }
end
args.outputs.sprites << tiled_background(args, 'sprites/grass.png',
dest_x: 0, dest_y: 0,
dest_w: 1280, dest_h: 720)
Coordinate System Origin Switching
args.grid.origin_center! # (0,0) at screen centre
args.grid.origin_bottom_left! # (0,0) at bottom-left (default)
Render targets inherit the current origin mode.
Tile-Based Large Maps
Only load the 9 tiles surrounding the player's position (3×3 grid of chunks):
CHUNK = 640
chunk_x = player.x.idiv(CHUNK)
chunk_y = player.y.idiv(CHUNK)
offset_x = 960 - (player.x - chunk_x * CHUNK)
offset_y = 960 - (player.y - chunk_y * CHUNK)
(-1..1).each do |cx|
(-1..1).each do |cy|
path = :"chunk_#{chunk_x + cx}_#{chunk_y + cy}"
args.outputs.sprites << {
x: offset_x + cx * CHUNK,
y: offset_y + cy * CHUNK,
w: CHUNK, h: CHUNK,
path: path
}
end
end
Screenshots
args.outputs.screenshots << {
filename: "screenshots/shot_#{Kernel.tick_count}.png",
x: 0, y: 0, w: 1280, h: 720,
chroma_key: { r: 0, g: 255, b: 0 } # optional: make green transparent
}
Static Sprites for Performance
# Add once; objects are held by reference and mutated in place:
if Kernel.tick_count == 0
args.state.stars = 500.map { { x: rand(1280), y: rand(720), w: 2, h: 2, path: :solid, r: 255, g: 255, b: 255 } }
args.outputs.static_sprites << args.state.stars
end
# Update positions without re-adding:
args.state.stars.each { |s| s[:x] = (s[:x] + 0.5) % 1280 }
Shaders (Indie/Pro)
args.outputs.sprites << {
x: 0, y: 0, w: 1280, h: 720,
path: 'sprites/scene.png',
shader: 'shaders/scanlines.glsl',
shader_tex1: :render_target_name,
# Uniforms:
shader_u_time: Kernel.tick_count / 60.0
}