Gfx, windows, and resizing

2018-05-28 · Superior

These early posts will mostly be me trying to work out how to use gfx-rs. I was previously using glium which is fantastic, but is sadly no longer being developed. So my choices are:

  1. Learn Vulkan - I’m not touching that one just yet.
  2. Use raw OpenGL bindings - but then I’m locked into OpenGL, or I have to add other backends later.
  3. Use gfx - so hey, that sounds good.

I opted to use the current released version (v0.17.1) but it’s currently undergoing a significant rearchitecture so I may move to that whenever it releases.

First thing I did was look for a tutorial, of which there really aren’t many. If you’re familiar with gfx, even a small tutorial would probably be really helpful for beginners. Anyway, I found this one and followed it fairly closely.

Resizing

Dust settles and I have an ugly fullscreen quad:

Fullscreen quad

Cool, now let’s resize the window and-

Not-so-fullscreen quad

Huh… that’s not very fullscreen. To make this work properly, we need to listen for the Resized event, then apply the new size to the window and views:

...
    WindowEvent::Resized(w, h) => {
        window.resize(w, h);
        gfx_window_glutin::update_views(&window, &mut color_view, &mut depth_view);
    }
...

Fullscreen stretchy quad

That’s an improvement - the contents of the window are resizing now. But ideally, I want the game to always render at the same aspect ratio. Namely 16:9. If the window is too tall, I want it to letterbox, and if it’s too wide, to pillarbox.

To accomplish that, we need two things: a scissor test to restrict rendering to a specific rectangle, and a viewport transformation to rescale whatever is being rendered so that it fits within that scissor rectangle.

(These two can be accomplished together by setting the viewport, which is possible in glium, but not currently supported by gfx.)

Scissor test

Setting up the scissor test is extremely easy. You just need one addition to the pipeline definition:

    pipeline pipe {
        vbuf: VertexBuffer<Vertex> = (),
        transform: ConstantBuffer<Transform> = "Transform",

        // Enables the scissor test
        scissor: Scissor = (),

        out: RenderTarget<ColorFormat> = "color",
    }

And also one addition to the pipe data:

    let mut data = pipe::Data {
        vbuf: vertex_buffer,
        transform: transform_buffer,

        // The rectangle to allow rendering within
        scissor: Rect { x: 0, y: 0, w: 1280, h: 720 },

        out: color_view,
    };

The question now is how do we determine the rectangle we want to draw in. We want the largest 16:9 rectangle we can fit in the window, so there are two possibilities: the rectangle as tall as the window, or the rectangle as wide as the window.

So for our first rectangle, we can assume its height is the same as the window. We then multiply by the aspect ratio (16/9) to get the width of that rectangle.

Our second rectangle has the same width as the window. Similarly, to get the height, we divide by the aspect ratio (multiply by 9/16).

Two possible viewport rectangles

One of these will fit the window, and the other one will be too large - depending on whether our window’s aspect ratio is smaller or larger than 16:9. We just have to take the smallest one. In the code below, we do this by choosing the smallest width of the two, and the smallest height of the two independently, but it’s the same result.

So here’s what the code for that looks like:

/// Return the desired viewport rect as a tuple of (left, bottom, width, height).
pub fn viewport_rect(screen_size: (u32, u32), target_aspect: (u32, u32)) -> (u32, u32, u32, u32) {
    // The size of our window, in pixels
    let (screen_w, screen_h) = screen_size;

    // The desired aspect ratio, e.g. (16, 9)
    let (ax, ay) = target_aspect;

    // Take the smallest of our two rectangles for each dimension.
    // Rect 1: height = screen_h, width = screen_h * aspect = screen_h * ax / ay
    // Rect 2: width = screen_w, height = screen_w / aspect = screen_w * ay / ax
    // Take the mininum of those two widths, and the minimum of those two heights.
    let width = std::cmp::min(screen_w, (screen_h * ax) / ay);
    let height = std::cmp::min(screen_h, (screen_w * ay) / ax);

    // To center the rect, offset by half of whatever space is left over.
    let left = (screen_w - width) / 2;
    let bottom = (screen_h - height) / 2;

    (left, bottom, width, height)
}

Correct scissor rect

Looks good to me! We always have the largest possible viewport with the correct aspect ratio. But notice how different parts of the quad are displayed for different viewports. That’s because we aren’t scaling the contents of the viewport to fit the scissor rect. Luckily this is pretty simple.

Viewport transformation

Essentially, we want to scale down the output of our vertex shader to fit the viewport. If the viewport is half as wide as the window, we want to scale it horizontally by half. If it’s a tenth as tall, we want to scale it vertically by a tenth, etc.

    let (window_w, window_h) = window_size;
    let (viewport_w, viewport_h) = viewport_size;
    let scale_x = viewport_w / window_w;
    let scale_y = viewport_h / window_h;

    let scale_matrix = [
        [scale_x, 0.0, 0.0, 0.0],
        [0.0, scale_y, 0.0, 0.0],
        [0.0, 0.0, 1.0, 0.0],
        [0.0, 0.0, 0.0, 1.0],
    ];

    let transform = Transform {
        // If you already had a transformation matrix, you would
        // multiply it by this one.
        transform: scale_matrix,
    };

Finished product

Yep. Good. Perfect.

So that’s something pretty basic, but also important to get right. Maybe next time I’ll draw something more interesting on screen.