Skip to content

Drawing (FmrbGfx)

FmrbGfx is the class that provides drawing APIs. In apps that inherit from FmrbApp, it is accessible as @gfx.

Nothing appears until present is called

All drawing commands are accumulated in a buffer. They reach the graphics side and appear on screen when @gfx.present is called.

Coordinate System

The origin (0, 0) is at the top-left of the window, with X increasing to the right and Y increasing downward. You cannot draw outside the window.

Each window has its own coordinate system. See the image below for reference.

Coordinate system

Color

Uses RGB332 (8-bit, R:3 G:3 B:2). Specified as an integer from 0x00 to 0xFF.

For some colors, constants like FmrbGfx::WHITE are also provided. A helper method FmrbGfx.rgb_to_332(r, g, b) is available to convert from 24-bit values.

You can also check colors on sites like this one.

A feature for using a single designated color as a transparent color is also available.

Drawing Layer Overview

Drawing layers in normal mode

Drawing layers

Drawing layers in fullscreen mode

Drawing layers in fullscreen

Color Constants

Constant Value
FmrbGfx::BLACK 0x00
FmrbGfx::WHITE 0xFF
FmrbGfx::RED 0xE0
FmrbGfx::GREEN 0x1C
FmrbGfx::BLUE 0x03
FmrbGfx::YELLOW 0xFC
FmrbGfx::CYAN 0x1F
FmrbGfx::MAGENTA 0xE3
FmrbGfx::GRAY 0x6D

Color Conversion

FmrbGfx.rgb_to_332(255, 128, 0)  # -> 0xF0 etc.
FmrbGfx.hsv_to_rgb(120, 255, 255) # -> [r, g, b] (each 0..255)

Control Methods

Method Purpose
clear(color) Clear the entire screen
present Apply accumulated drawing commands to the screen

Basic Shapes

All take a color (RGB332) as the last argument.

Method Signature
set_pixel set_pixel(x, y, color)
draw_line draw_line(x1, y1, x2, y2, color)
draw_thick_line draw_thick_line(x0, y0, x1, y1, thickness, color). Stacked 1-pixel lines — the backend has no thick-line primitive
draw_rect draw_rect(x, y, w, h, color) (outline only)
fill_rect fill_rect(x, y, w, h, color) (filled)
blend_rect blend_rect(x, y, w, h, color, mode:) (mode: 0=ADD, 1=XOR)
draw_circle draw_circle(x, y, r, color)
fill_circle fill_circle(x, y, r, color)
draw_ellipse draw_ellipse(x, y, rx, ry, color)
fill_ellipse fill_ellipse(x, y, rx, ry, color)
draw_round_rect draw_round_rect(x, y, w, h, radius, color)
fill_round_rect fill_round_rect(x, y, w, h, radius, color)
draw_triangle draw_triangle(x0, y0, x1, y1, x2, y2, color)
fill_triangle fill_triangle(x0, y0, x1, y1, x2, y2, color)
draw_arc draw_arc(x, y, r0, r1, angle0, angle1, color)
fill_arc fill_arc(x, y, r0, r1, angle0, angle1, color)

Angles for draw_arc / fill_arc are integers in degrees. r0 is the inner radius, r1 is the outer radius.

Text Drawing

@gfx.set_text_size(2)             # 1 to 4
@gfx.draw_text(10, 20, "Hello",
               FmrbGfx::BLACK)    # No bg -> transparent
@gfx.draw_text(10, 40, "Hi",
               FmrbGfx::WHITE,
               FmrbGfx::BLUE)     # With bg -> opaque
Method Purpose
set_text_size(size) Text size. 1 to 4
draw_text(x, y, text, color [, bg_color], mixed: false) Draw text. mixed: true enables ASCII/Japanese hybrid rendering
set_font(family, size = nil) Switch font (see below)
current_font / current_text_size Current font / size (read-only)

Japanese Text and Font Switching

By switching to a Japanese font with set_font(family, size), you can draw UTF-8 strings directly.

# Default ASCII font (Font0, 6x8)
@gfx.set_font(:default)
@gfx.draw_text(10, 20, "Hello", FmrbGfx::BLACK)

# Japanese 8px (misaki_8, small size matching the system UI)
@gfx.set_font(:ja, 8)
@gfx.draw_text(10, 40, "こんにちは", FmrbGfx::BLACK)

# Japanese 12px (efontJA_12, larger and more readable)
@gfx.set_font(:ja, 12)
@gfx.draw_text(10, 60, "ファミリーmruby", FmrbGfx::BLACK)

Supported Fonts

family size Description
:default (not specifiable) Font0 6x8 ASCII. Default at startup
:ja 8 misaki_8 8x8, same size as system UI
:ja 12 efontJA_12 12x12, more readable
:ja 16 efontJA_16 16x16, for headings and slides
:ja_bold 12 The bold cut of efontJA_12

set_font tells you what it chose

A machine does not have to carry every font. set_font picks the nearest thing it has — a size it does not carry falls back to 12, and a bold it does not carry to the regular cut — and returns what it actually selected, as [family, size] (or [:default]).

got = @gfx.set_font(:ja_bold, 12)
bold_by_hand = (got[0] != :ja_bold)   # draw twice, one pixel apart, if it matters

That return value is also what text_width and font_height then measure, so a layout built from them stays right on a machine with fewer fonts.

Hybrid Drawing (mixed: true)

Strings containing mixed ASCII and Japanese characters can be drawn in a single draw_text call. ASCII portions are rendered with Font0 (6x8) and UTF-8 multibyte portions with misaki_8 (8x8).

@gfx.draw_text(10, 20, "puts 'こんにちは'",
               FmrbGfx::BLACK, mixed: true)

Convenient for code examples and bilingual UI strings.

draw_text_mixed(x, y, str, color, bg_color = nil) is the same thing with positional arguments. Keyword arguments build a Hash on every call, which a redraw path that must not allocate cannot afford; this form does not.

draw_window_frame saves and restores the font

FmrbApp#draw_window_frame always draws the title bar with the default 6x8 font and then restores the font setting from before the call. There is no need to call set_font again each frame in your app.

JA font loading cost

The first call to set_font(:ja, ...) takes several tens of milliseconds as the graphics side prepares the font data. Ideally, call it once in on_create.

Example (Japanese)

class HelloJaApp < FmrbApp
  def on_create
    clear_user_area(FmrbGfx::WHITE)
    @gfx.set_font(:ja, 12)
    @gfx.draw_text(@user_area_x0 + 8, @user_area_y0 + 8,
                   "こんにちは、Family mruby!", FmrbGfx::BLACK)
    draw_window_frame
    @gfx.present
  end
end

HelloJaApp.new.start

The Fonts pages of the PicoRuby demo (/app/demo/picoruby.app.rb) switch between them all: the default font, Japanese at 8, 12 and 16 pixels, mixed and hybrid drawing, and Japanese scaled up.

Images

An image is decoded and held on the graphics side, which reads its own filesystem. Getting one on screen is therefore three steps: put the file where that side can read it, create the image from it, draw it.

@gfx.sync_file("/usr/share/backgrounds/BG_sample.png")
img = @gfx.create_image("/usr/share/backgrounds/BG_sample.png")
@gfx.draw_image(img[:id], x: 10, y: 20)
@gfx.delete_image(img[:id])

Or all of it at once:

@gfx.load_image("/usr/share/backgrounds/BG_sample.png", coord: :center)
Method Returns / does
sync_file(path, dest: nil) Make the graphics side's copy match this one, transferring only when it differs (size + CRC32). This — not file_status[:exists] — is what to use for an asset, or an edited file stays stale for ever
transfer_file(path, dest: nil) Transfer unconditionally
file_status(path) {exists:, size:} on the graphics side
create_image(path) {id:, width:, height:}, or nil. PNG, up to about 200 KB
draw_image(id, x: 0, y: 0, scale_x: 1.0, scale_y: 0.0) Draw it. scale_y: 0.0 means "the same as scale_x"
draw_tile(image_id, src_x, src_y, w, h, dst_x:, dst_y:) Stamp a sub-region of a SpriteImage onto the canvas
delete_image(id) Release it
load_image(path, coord: nil) Sync, create, draw, present and delete in one call. coord: takes [x, y] or :center

Two formats, two doors

create_image takes a PNG. A sprite sheet is a different thing: an RGB332 BMP loaded with SpriteImage#load_bmp. Handing a BMP to create_image does not raise — you get an empty image the size of the screen and nothing appears. See Image & Icon Files.

When to use draw_tile

You can directly stamp part of a SpriteImage onto the canvas without creating a SpriteInstance. This is suitable for BG rendering where you tile 16x16 cells from a tile sheet image one by one. The transparent color of a SpriteImage created with use_transparent: true is respected, enabling layered map drawing.

sheet = SpriteImage.new(@gfx, width: 64, height: 32,
                          transparent_color: 0, use_transparent: true)
sheet.load_bmp("/usr/share/sprites/test/tilesheet.bmp")
# Draw 16x16 from tilesheet at (0, 0) to canvas at (32, 16)
@gfx.draw_tile(sheet.id, 0, 0, 16, 16, dst_x: 32, dst_y: 16)

For a higher-level wrapper, see TileMap.

Masks

A 1bpp mask cuts a shape out of a SpriteImage as it is blitted: pixels are sampled from the image and written only where the mask bit is set.

Method
create_mask(width, height, data) Upload a mask and get its id. data is ceil(width / 8) * height bytes, MSB first; a 1 bit draws
draw_image_masked(image_id, mask_id, x:, y:) Blit through the mask
delete_mask(mask_id) Release it. Ordered behind any drawing that still refers to it

Reading the canvas back

get_pixel(x, y) returns one RGB332 byte, and 0 outside the canvas. It is a synchronous round trip to the graphics side, so it is not something to do per pixel in a loop.

Hardware scrolling (Modern only)

set_viewport(src_x, src_y, w, h) shows a moving window onto a canvas larger than the one the app draws in, without redrawing anything. The canvas is addressed as a torus — the source rectangle wraps around its edges — so a canvas slightly larger than the viewport can scroll an arbitrarily large world if newly exposed tiles are stamped as it moves. clear_viewport undoes it and composites the whole canvas again. The Retro backend ignores both, so gate them on FmrbConst::CHIP_MODEL == "ESP32-P4".

Keeping sprites inside a rectangle

Sprites are composited on top of everything the canvas drew, so without a clip they paint over the window frame and title bar the app drew into the same canvas.

Method Purpose
set_sprite_clip(x, y, w, h) Confine this canvas's sprites to that rectangle
clear_sprite_clip Let them use the whole canvas again

The rectangle is in sprite coordinates — the ones passed to SpriteInstance#move — and is clamped to the canvas. A windowed app starts with its user area already set, so this is only needed to narrow it further:

# reserve the top 10px for the score; sprites stay below it
@gfx.set_sprite_clip(@user_area_x0, @user_area_y0 + 10,
                     @user_area_width, @user_area_height - 10)

Saving the screen to a file

export_frame(path) writes the picture the last present put on screen to a file, on the display side's filesystem. It does not present: send present first, then this, and the two keep their order.

@gfx.present
@gfx.export_frame("/mnt/sd/shot.jpg")
Machine
Modern A JPEG, written by the SoC's encoder into the filesystem the two sides share, so File.exist? can tell when it is done
Simulator A BMP, in the graphics side's own storage, which the app cannot see
Retro Not supported; it says so in the log

Video (Modern only)

video_open plays a file of concatenated JPEG frames into the canvas and hands back a player. It returns nil anywhere else, so an app can fall back.

@video = @gfx.video_open("/mnt/sd/clip.mjpg", x: 8, y: 8, fps: 15, loop: true)
if @video
  @video.play
  ...
  @video.pause
  @video.rewind
  @video.stop
end
Method
width / height The picture size the file turned out to have
play / pause / stop / rewind Transport
status 0 idle, 1 playing, 2 paused, 3 finished
playing? / finished? The two states worth asking about

Whatever the app drew elsewhere on the canvas stays; do not draw inside the picture rectangle while it plays. One player exists at a time.

Composite Region Specification (set_composite_regions)

@gfx.set_composite_regions([
  {dst_x: 0,   dst_y: 0,   w: 4, h: 4, transparent: true},   # Top-left rounded corner
  {dst_x: w-4, dst_y: 0,   w: 4, h: 4, transparent: true},   # Top-right
  {dst_x: 0,   dst_y: 4,   w: w, h: h - 8, transparent: false},  # Center is opaque
  # ...
])

A performance API that specifies which rectangles of the canvas are composited and how (transparent mode / opaque mode). Useful for rounded-corner windows where only the corners use transparency while the center uses a fast memcpy path. Maximum 8 regions. Passing nil or [] clears the setting.

Normally, the system configures this via the rounded_corners flag in .toml (App Configuration > rounded_corners), so user apps rarely need to use this directly.

NTSC Output Adjustment (Retro only)

Method Purpose Range
set_output_level(level) Overall brightness 0..255
set_chroma_level(level) Saturation (color burst amplitude) 0..255

Used for color adjustment on CRT monitors (see NTSC Output Test sample).

Example: Drawing Shapes

class ShapesApp < FmrbApp
  def on_create
    clear_user_area(FmrbGfx::WHITE)
    x = @user_area_x0 + 5
    y = @user_area_y0 + 5
    @gfx.fill_rect(x, y, 30, 20, FmrbGfx::RED)
    @gfx.fill_circle(x + 60, y + 10, 10, FmrbGfx::GREEN)
    @gfx.draw_round_rect(x + 90, y, 30, 20, 4, FmrbGfx::BLUE)
    @gfx.draw_text(x, y + 30, "Shapes",
                   FmrbGfx::BLACK)
    draw_window_frame
    @gfx.present
  end

  def on_update
    500
  end
end

ShapesApp.new.start

Notes

Batch drawing with present

Within on_update, which is called at high frequency, it is recommended to issue multiple drawing commands and then call present only once. Calling present after every individual command can saturate the UART bandwidth.

Do not draw outside the window frame

Draw within the @user_area_x0/y0/width/height bounds. Overwriting the title bar or borders will break the visual appearance.

  • For sprites and tile maps, see Sprite
  • GfxBlock for efficient dynamic GUI rendering is also described in Sprite