Photo by Marie P on Unsplash

Get started with graphics programming in Rust

Matthias Friedrich

--

I am currently strengthening my Rust skills. While I usually work on backend applications, I wanted to know if I could also use Rust to build GUI applications or games.

To start, I wanted to keep things as simple as possible and limit my spike functionality to the following tasks: creating and displaying a window of a specific size, clearing the view with color, and drawing some shapes and some text. Ideally, even while I will focus on 2D graphics, the rendering should be hardware accelerated.

I found Piston — a modular game engine entirely written in Rust. Piston integrates with various external APIs; thus, it supports multiple window- and graphics backends, including SDL2 and OpenGL. Since I know little about OpenGL yet, I tried several approaches involving SDL2, gl-rs, and pistoncore-glutin_window, but all those things felt too complicated.

Finally, I tossed some code, got rid of SDL2, and build something neat and straightforward based on the following crates: piston, piston2d-graphics, piston_window, and piston2d-opengl_graphics. The latter is an OpenGL 2D graphics backend.

I added the following dependencies to the Cargo.toml file:

piston = "0.49.0"
piston2d-graphics = "0.36.0"
piston_window = "0.107.0"
piston2d-opengl_graphics = "0.72.0"
rusttype = "0.8.2"
fnv = "1.0.6"

Initializing the window and graphics backend

The following code creates a new window (that uses the Glutin window backend by default) and OpenGL as the graphics backend.

let opengl = OpenGL::V4_5;
let mut window: PistonWindow = WindowSettings::new("title", [800, 600])
.graphics_api(opengl)
.exit_on_esc(true)
.build()
.unwrap();
let gl = GlGraphics::new(opengl);

Next, the event loop will be created.

const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];

struct App {
pub(crate) gl: GlGraphics,
}

impl App {
fn render(&mut self, args: &RenderArgs) {
self.gl.draw(args.viewport(), |c, g| {
g.clear_color(BLACK);
});
}

fn update(&mut self, args: &UpdateArgs) { }
}

fn run_loop(app: &mut App, w: &mut PistonWindow) {
let mut events = Events::new(EventSettings::new());
while let Some(e) = events.next(w) {
if let Some(args) = e.render_args() {
app.render(&args);
}

if let Some(args) = e.update_args() {
app.update(&args);
}
}
}

let mut app = App { gl };
run_loop(&mut app, &mut window);

So far, most of the tasks I wanted to accomplish are solved—the application creates and displays a new window that is rendered using OpenGL (the code clears the view using black color) and has a basic event loop that invokes methods to update and render the next frame, which is the foundation for animated graphics.

Drawing rectangles

Drawing a rectangle is fairly simple. A trait for the OpenGL data object that hides some implementation details (such as draw_state and transform parameters) helps improve my code's readability.

use graphics::{Context, Rectangle};
use opengl_graphics::{GlGraphics};

pub trait DrawRectangle {
fn draw_rectangle(
&mut self,
r: [f64; 4],
color: [f32; 4], c: &Context);
}

impl DrawRectangle for GlGraphics {
fn draw_rectangle(
&mut self,
r: [f64; 4],
color: [f32; 4],
c: &Context) {
Rectangle::new(color)
.draw(r, &c.draw_state, c.transform, self);
}

let r = rectangle_by_corners(5, 5, 55, 75);
g.draw_rectangle(r, WHITE, c);

Regarding rectangles and primitives such as points and lines, Piston also provides a library with appropriate structs named piston2d-shapes.

Drawing texts

The rendering of text in Piston (or OpenGL in general) is complicated — or at least the engine does not provide any good abstraction to simplify the output of texts.

First, a font must be loaded, which can be accomplished using the load_font method of the PistonWindow.

let ttf = PathBuf::from_str("BalooDa2-Medium.ttf").unwrap();
let mut glyphs = window.load_font(ttf).unwrap());

The method returns a GlyphCache object that needs to be passed to the draw method of Text. What I don´t like is that the load_font method requires a true-type-font file path — I prefer a method that allows me to load a font from a buffer. It is possible, but creating the GlyphCache manually is required. For instance:

let mut font: Vec<u8> = vec![];
read_resource("BalooDa2-Medium.ttf", &mut font);
let mut glyphs = opengl_graphics::GlyphCache::from_bytes(
font.as_ref(), (), TextureSettings::new()).unwrap();

As I did with the rectangle, I defined a trait (for instance, to be implemented for GlGraphics) that simplifies the drawing of texts but hides the boilerplate code.

use crate::piston_window::Transformed;
use crate::text::MeasureText;

use graphics::types::FontSize;
use graphics::{Context, Text};
use opengl_graphics::{GlGraphics, GlyphCache};

trait DrawText {
fn draw_text(
&mut self,
text: &str,
r: [f64; 4],
color: [f32; 4],
size: FontSize,
halign: TextAlignment,
valign: TextVerticalAlignment,
glyphs: &mut GlyphCache,
c: &Context,
);
}

An implementor of the trait shall not just draw the given text to a coordinate but shall also do some basic calculations to align the text within a given rectangle.

impl DrawText for GlGraphics {
fn draw_text(
&mut self,
text: &str,
r: [f64; 4],
color: [f32; 4],
size: FontSize,
halign: TextAlignment,
valign: TextVerticalAlignment,
glyphs: &mut GlyphCache,
c: &Context,
) {
let x0 = r[0];
let y0 = r[1];
let x1 = r[2];
let y1 = r[3];

let t = Text::new_color(color, size);
let size = t.measure(text, glyphs).unwrap();
fn center(p0: f64, p1: f64, wh: f64) -> f64 {
p0 + ((p1 - p0) / 2.0) - (wh / 2.0)
}
let x = match halign {
TextAlignment::Left => x0,
TextAlignment::Right => x1 - size.width,
TextAlignment::Center => center(x0, x1, size.width),
};

let y = match valign {
TextVerticalAlignment::Top => y0,
TextVerticalAlignment::Bottom => y1 - size.height,
TextVerticalAlignment::Center => center(y0, y1, size.height),
};

let transform = c.transform.trans(x, y);
let draw_state = c.draw_state;
t.draw(text, glyphs, &draw_state, transform, self).unwrap();
}
}

Finally, drawing text becomes a one-liner (more or less).

let r = rectangle_by_corners(0.0, 25.0, 65.0, 100.0);
let ha = TextAlignment::Center;
let va = TextVerticalAlignment::Top;
g.draw_text("Lorem ipsum", r, RED, 28, ha, va, glyphs, &c);

Measure text dimensions

The function must determine the text dimensions to align the text horizontally or vertically. I don´t know if Piston provides such functionality out of the box, but I learned from the opengl_graphics code that the draw method also calculates character dimensions, so I borrowed some pieces for another trait.

use graphics::character::CharacterCache;
use graphics::Text;
use piston::window::Size;
trait MeasureText {
fn measure<C>(
&self,
text: &str,
cache: &mut C) -> Result<Size, ()>
where
C: CharacterCache;
}

This is a simple implementation of the MeasureText trait:

impl MeasureText for Text {
fn measure<C>(
&self,
text: &str,
cache: &mut C) -> Result<Size, ()>
where
C: CharacterCache,
{
let mut w = 0.0;
let mut h = 0.0;
for ch in text.chars() {
let character = cache.character(self.font_size, ch)
.ok().unwrap();
let (left, top) = (character.left(), character.top());
w += character.advance_width() + left;
h = (character.advance_height() + top).max(h);
}
let result = Size {
width: w as f64,
height: h as f64,
};
Ok(result)
}
}

Now, my little test application also allows me to draw texts to view pretty simply — the goals I listed initially have been accomplished.

--

--