Rendering Sprite Sheets in Rust
I've made some progress learning OpenGL. For tile locations on screen, I calculate all the vertex positions per tile (2 triangles per tile, 6 vertices per triangle = 12 vertices) and store them in a vertex buffer object. I add the 0.0 Z coordinate in the shader, so that doesn't need to be stored, at least.
Here's what the geometry looks like for a 1920x1200 display configured for 32x32 pixel tiles:
Screenshot is from NVIDIA Nsight Graphics, a pretty awesome tool for a beginner like me. RenderDoc is great, too, but Nsight Graphics was able to immediately tell me what was wrong with my render pipeline. (Remember to bind the vertex array every draw call)
Here's the code for calculating the tile vertices:
let mut tile = 0.0;
loop {
let verts = get_tex_vertices(512.0, 512.0, 32.0, 32.0, tile);
//upper right
vertices.push(xf + 32.0);
vertices.push(yf + 32.0);
//upper left
vertices.push(xf);
vertices.push(yf + 32.0);
//lower left
vertices.push(xf);
vertices.push(yf);
//upper right
vertices.push(xf + 32.0);
vertices.push(yf + 32.0);
//lower left
vertices.push(xf);
vertices.push(yf);
//lower right
vertices.push(xf + 32.0);
vertices.push(yf);
uvs.extend_from_slice(&verts);
tile += 1.0;
if tile > 255.0 {
tile = 0.0;
}
xf += 32.0;
if xf > 1920.0 {
xf = 0.0;
yf += 32.0;
}
if yf > 1200.0 {
break;
}
}
Screen resolution is temporarily hard coded so that I could check the geometry without running fullscreen. Generally, the vertex shader wants vertices in normalized device coordinates which range from -1.0,-1.0 to 1.0,1.0. Right now I'm normalizing the coordinates in the shader like so:
#version 330
layout (location = 0) in vec2 vert;
layout (location = 1) in vec2 _uv;
out vec2 uv;
void main()
{
uv = _uv;
gl_Position = vec4((vert.x / 1920.0 * 2.0) - 1.0, (vert.y / 1200.0 * 2.0) - 1.0, 0.0, 1.0);
};
But when I rewrite my shaders in a moment I'll just handle it on the CPU since I only need to calculate those vertices once.
Next I had to figure out how to access individual sprites in a sprite sheet (also called a texture atlas). I'm targeting 32x32 tile size, so I looked for an example to do testing with. I found a cool tileset for Dwarf Fortress by Vettlingr.
Here's the tileset:
Note: I'm not planning on using these graphics officially, it's just for demonstration purposes.
The image is 512x512 pixels. Each tile is 32x32 pixels, which means there are 16x16 (or 256) tiles total. In the code above I call get_tex_vertices() to generate the texture coordinates for each tile. Textures use yet another coordinate system that ranges from 0.0,0.0 to 1.0,1.0.
Here's the code for calculating texture coordinates based on texture size, sprite size, and sprite index:
// tile index = 0 from left to right, top to bottom.
fn get_tex_vertices(
sheet_w: f32,
sheet_h: f32,
tile_w: f32,
tile_h: f32,
tile_index: f32,
) -> [f32; 12] {
let mut vertices: [f32; 12] = [0.0; 12];
let cols = sheet_w / tile_w;
let x = tile_index % cols;
let y = (tile_index / cols).floor();
//invert y by default
//lower right
vertices[0] = (x * tile_w + tile_w) / sheet_w;
vertices[1] = (y * tile_h) / sheet_h;
//lower left
vertices[2] = (x * tile_w) / sheet_w;
vertices[3] = (y * tile_h) / sheet_h;
//upper left
vertices[4] = (x * tile_w) / sheet_w;
vertices[5] = (y * tile_h + tile_h) / sheet_h;
//lower right
vertices[6] = (x * tile_w + tile_w) / sheet_w;
vertices[7] = (y * tile_h) / sheet_h;
//upper left
vertices[8] = (x * tile_w) / sheet_w;
vertices[9] = (y * tile_h + tile_h) / sheet_h;
//upper right
vertices[10] = (x * tile_w + tile_w) / sheet_w;
vertices[11] = (y * tile_h + tile_h) / sheet_h;
vertices
}
This code automatically inverts the y coordinates. That's because there are more coordinate systems fighting each other here. Unless configured differently, drawing in OpenGL at the origin (0,0) usually means the lower left corner of the screen. Many image formats have their 0,0 at the upper left of the screen. Instead of rotating lots of image data around in memory, I'll rotate the texture coordinates instead.
Here's the vertex geometry of the sprite sheet visualized:
I put together a few transforms for texture coordinates. This lets me pre-calculate flipped, rotated, mirrored, etc. texture coordinates for animation later on.
Here are a few examples:
fn transform_uvs(
uvs: &mut [f32; 12],
sheet_w: f32,
sheet_h: f32,
tile_w: f32,
tile_h: f32,
tile_index: f32,
transform: Transforms,
) {
let cols = sheet_w / tile_w;
if transform == Transforms::FlipVertical {
let y = (tile_index / cols).floor();
let y_low = (y * tile_h) / sheet_h;
let y_high = (y * tile_h + tile_h) / sheet_h;
let mut flip = |index: usize| {
if uvs[index] == y_low {
uvs[index] = y_high;
} else {
uvs[index] = y_low;
}
};
// odd vertices = y coordinates
for i in (1..=11).step_by(2) {
flip(i);
}
}
if transform == Transforms::FlipHorizontal {
let x = tile_index % cols;
let x_low = (x * tile_w) / sheet_w;
let x_high = (x * tile_w + tile_w) / sheet_w;
let mut flip = |index: usize| {
if uvs[index] == x_low {
uvs[index] = x_high;
} else {
uvs[index] = x_low;
}
};
//even vertices = x coordinates
for i in (0..=10).step_by(2) {
flip(i);
}
}
if transform == Transforms::Rotate90Left {
let x = tile_index % cols;
let y = (tile_index / cols).floor();
uvs[0] = (x * tile_w + tile_w) / sheet_w;
uvs[1] = (y * tile_h + tile_h) / sheet_h;
uvs[2] = (x * tile_w + tile_w) / sheet_w;
uvs[3] = (y * tile_h) / sheet_h;
uvs[4] = (x * tile_w) / sheet_w;
uvs[5] = (y * tile_h) / sheet_h;
uvs[6] = (x * tile_w + tile_w) / sheet_w;
uvs[7] = (y * tile_h + tile_h) / sheet_h;
uvs[8] = (x * tile_w) / sheet_w;
uvs[9] = (y * tile_h) / sheet_h;
uvs[10] = (x * tile_w) / sheet_w;
uvs[11] = (y * tile_h + tile_h) / sheet_h;
}
if transform == Transforms::Rotate90LeftHMirror {
let x = tile_index % cols;
let y = (tile_index / cols).floor();
uvs[0] = (x * tile_w + tile_w) / sheet_w;
uvs[1] = (y * tile_h) / sheet_h;
uvs[2] = (x * tile_w + tile_w) / sheet_w;
uvs[3] = (y * tile_h + tile_h) / sheet_h;
uvs[4] = (x * tile_w) / sheet_w;
uvs[5] = (y * tile_h + tile_h) / sheet_h;
uvs[6] = (x * tile_w + tile_w) / sheet_w;
uvs[7] = (y * tile_h) / sheet_h;
uvs[8] = (x * tile_w) / sheet_w;
uvs[9] = (y * tile_h + tile_h) / sheet_h;
uvs[10] = (x * tile_w) / sheet_w;
uvs[11] = (y * tile_h) / sheet_h;
}
}
This math is specific to the coordinates I generated before. It's also specific for my vertex buffer layout. It's just there to give an idea on how to approach the problem for other people trying to learn how to 2d graphics in 2023 =)
Next post I'm going to finish the shaders, allowing me to update tile indexes on demand and render many layers. I'm also going to add in palette support. My near term goal is to import the Dwarf Fortress raws and basically duplicate the rendering system.
Here's one last picture showing my title screen with the Vettlingr tiles in the background: