⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

Texture Mapping

Rasterizing triangles ended with three barycentric weights and the observation that they interpolate anything you attach to the corners. That chapter attached colours. Attach a pair of coordinates into another image instead and the triangle is textured, and not one line of the rasteriser changes to make it happen.

The program

const std = @import("std");

const dst_width: usize = 60;
const dst_height: usize = 16;

var framebuffer: [dst_width * dst_height]u32 = undefined;

fn rgb(r: u8, g: u8, b: u8) u32 {
    return (@as(u32, r) << 16) | (@as(u32, g) << 8) | @as(u32, b);
}

const backdrop = rgb(0, 0, 0);
const dark = rgb(40, 44, 52);
const light = rgb(240, 246, 243);
const amber = rgb(247, 164, 29);

/// The canvas from the previous chapter, trimmed to what this one uses. It is
/// the thing being written to, so it carries a stride.
const Canvas = struct {
    pixels: []u32,
    width: usize,
    height: usize,
    stride: usize,

    fn set(self: Canvas, x: usize, y: usize, color: u32) void {
        if (x >= self.width or y >= self.height) return;
        self.pixels[y * self.stride + x] = color;
    }

    fn fill(self: Canvas, color: u32) void {
        for (0..self.height) |y| {
            for (0..self.width) |x| self.set(x, y, color);
        }
    }
};

/// A texture is only ever read, so it has no stride: nothing samples a
/// rectangle out of the middle of one. Giving it its own type rather than
/// reusing `Canvas` is a claim about direction, and the compiler enforces it,
/// because a `Texture` has no `set`.
const Texture = struct {
    pixels: []const u32,
    width: usize,
    height: usize,
};

var texels: [8 * 8]u32 = undefined;

/// Two-texel checks, so the pattern survives being scaled down, and one amber
/// corner, so a tile that has been flipped or wrapped is obvious rather than
/// merely different.
fn buildTexture() Texture {
    for (0..8) |y| {
        for (0..8) |x| {
            const check = ((x / 2) + (y / 2)) % 2 == 0;
            texels[y * 8 + x] = if (check) light else dark;
        }
    }
    for (0..2) |y| {
        for (0..2) |x| texels[y * 8 + x] = amber;
    }
    return .{ .pixels = &texels, .width = 8, .height = 8 };
}

/// A corner of a triangle: where it lands on the canvas, and where it reads
/// from in the texture. `u` and `v` run 0 to 1 across the image regardless of
/// how many pixels the image has, which is what lets the same coordinates
/// address a texture of any size.
const Vertex = struct { x: f32, y: f32, u: f32, v: f32 };

/// Twice the signed area of the triangle abp. Positive on one side of the line
/// ab and negative on the other, which is the whole test.
fn edge(ax: f32, ay: f32, bx: f32, by: f32, px: f32, py: f32) f32 {
    return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
}

/// Nearest-neighbour sampling. `wrap` decides what a coordinate outside 0..1
/// means: repeat the image, or hold the edge texel.
fn sample(tex: Texture, u: f32, v: f32, wrap: bool) u32 {
    const w: i32 = @intCast(tex.width);
    const h: i32 = @intCast(tex.height);
    var tx: i32 = @intFromFloat(@floor(u * @as(f32, @floatFromInt(w))));
    var ty: i32 = @intFromFloat(@floor(v * @as(f32, @floatFromInt(h))));

    if (wrap) {
        // `@mod`, not `%`: the remainder operator keeps the sign of the
        // dividend, so a u of -0.1 would index backwards out of the texture.
        tx = @mod(tx, w);
        ty = @mod(ty, h);
    } else {
        tx = @max(0, @min(w - 1, tx));
        ty = @max(0, @min(h - 1, ty));
    }

    return tex.pixels[@intCast(ty * w + tx)];
}

/// The same barycentric rasteriser as the triangles chapter, with one change:
/// the three weights interpolate `u` and `v` instead of a colour.
fn triangle(dst: Canvas, tex: Texture, a: Vertex, b: Vertex, c: Vertex, wrap: bool) void {
    const area = edge(a.x, a.y, b.x, b.y, c.x, c.y);
    if (area == 0) return;

    const min_x: usize = @intFromFloat(@max(0, @floor(@min(a.x, @min(b.x, c.x)))));
    const min_y: usize = @intFromFloat(@max(0, @floor(@min(a.y, @min(b.y, c.y)))));
    const max_x: usize = @intFromFloat(@max(0, @ceil(@max(a.x, @max(b.x, c.x)))));
    const max_y: usize = @intFromFloat(@max(0, @ceil(@max(a.y, @max(b.y, c.y)))));

    var y = min_y;
    while (y <= max_y and y < dst.height) : (y += 1) {
        var x = min_x;
        while (x <= max_x and x < dst.width) : (x += 1) {
            // Sample at the pixel's centre, which is what stops a shared edge
            // between two triangles from dropping a line of pixels.
            const px = @as(f32, @floatFromInt(x)) + 0.5;
            const py = @as(f32, @floatFromInt(y)) + 0.5;

            const w0 = edge(b.x, b.y, c.x, c.y, px, py) / area;
            const w1 = edge(c.x, c.y, a.x, a.y, px, py) / area;
            const w2 = edge(a.x, a.y, b.x, b.y, px, py) / area;
            if (w0 < 0 or w1 < 0 or w2 < 0) continue;

            // The interpolation. Three weights, applied to the corners' texture
            // coordinates exactly as they would be to their colours.
            const u = w0 * a.u + w1 * b.u + w2 * c.u;
            const v = w0 * a.v + w1 * b.v + w2 * c.v;
            dst.set(x, y, sample(tex, u, v, wrap));
        }
    }
}

/// Four corners, two triangles, sharing the diagonal.
fn quad(dst: Canvas, tex: Texture, corners: [4]Vertex, wrap: bool) void {
    triangle(dst, tex, corners[0], corners[1], corners[2], wrap);
    triangle(dst, tex, corners[0], corners[2], corners[3], wrap);
}

const ramp = " .:-=+*#%@";

fn dump(canvas: Canvas, out: *std.Io.Writer) !void {
    var row: [dst_width]u8 = undefined;
    for (0..canvas.height) |y| {
        for (0..canvas.width) |x| {
            const color = canvas.pixels[y * canvas.stride + x];
            const luma = (((color >> 16) & 0xff) * 77 +
                ((color >> 8) & 0xff) * 150 +
                (color & 0xff) * 29) >> 8;
            row[x] = ramp[(luma * (ramp.len - 1)) / 255];
        }
        try out.writeAll(std.mem.trimEnd(u8, row[0..canvas.width], " "));
        try out.writeByte('\n');
    }
}

pub fn main(init: std.process.Init) !void {
    var buf: [8192]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    const tex = buildTexture();
    const dst = Canvas{
        .pixels = &framebuffer,
        .width = dst_width,
        .height = dst_height,
        .stride = dst_width,
    };

    // Left: the image once, corner to corner. Right: the same rectangle with
    // coordinates running to 2 instead of 1, so the sampler walks off the edge
    // of the texture twice in each direction and wrapping brings it back.
    dst.fill(backdrop);
    quad(dst, tex, .{
        .{ .x = 2, .y = 0, .u = 0, .v = 0 },
        .{ .x = 26, .y = 0, .u = 1, .v = 0 },
        .{ .x = 26, .y = 16, .u = 1, .v = 1 },
        .{ .x = 2, .y = 16, .u = 0, .v = 1 },
    }, false);
    quad(dst, tex, .{
        .{ .x = 32, .y = 0, .u = 0, .v = 0 },
        .{ .x = 56, .y = 0, .u = 2, .v = 0 },
        .{ .x = 56, .y = 16, .u = 2, .v = 2 },
        .{ .x = 32, .y = 16, .u = 0, .v = 2 },
    }, true);
    try out.writeAll("uv 0..1, and uv 0..2 wrapped\n");
    try dump(dst, out);

    // A trapezoid: the top edge is shorter than the bottom, so no single
    // affine map covers all four corners. Each triangle gets its own, and the
    // shared diagonal is where the two disagree.
    dst.fill(backdrop);
    quad(dst, tex, .{
        .{ .x = 19, .y = 0, .u = 0, .v = 0 },
        .{ .x = 41, .y = 0, .u = 1, .v = 0 },
        .{ .x = 57, .y = 16, .u = 1, .v = 1 },
        .{ .x = 3, .y = 16, .u = 0, .v = 1 },
    }, false);
    try out.writeAll("\nthe same uv on a trapezoid, and the seam it produces\n");
    try dump(dst, out);

    try out.flush();
}

What just happened

The interpolation is the same three weights. w0, w1 and w2 are computed exactly as they were for Gouraud shading. The only edit is what they are multiplied by:

const u = w0 * a.u + w1 * b.u + w2 * c.u;
const v = w0 * a.v + w1 * b.v + w2 * c.v;

Coordinates run 0 to 1, not 0 to 8. The texture is 8 pixels square and nothing outside sample mentions that. Normalized coordinates mean the same four corners address a 16 pixel texture, or a 4096 pixel one, without the geometry knowing which it got.

Going past 1 is a decision, not an error. The right-hand square asks for coordinates up to 2. Wrapping turns that into the image twice in each direction. Clamping would have stretched the edge texels into a smear. Both are reasonable and the sampler has to be told which.

The trapezoid has a seam. Look along its diagonal. A four-cornered shape is drawn as two triangles, each gets its own affine interpolation, and when the shape is not a parallelogram those two maps do not agree along the edge they share.

The seam is not a bug in the rasteriser

It is the honest result of what an affine map can represent. A triangle’s three corners fix a linear relationship between position and texture coordinate exactly, with nothing left over. Four corners are one constraint too many, so splitting into two triangles solves two separate problems and the answers meet at the diagonal without matching.

Draw a parallelogram instead and the seam disappears, because there a single affine map does satisfy all four corners and the two triangles agree by construction.

This is the same defect as perspective in 3D, seen in 2D where it is easier to look at. A wall receding into the distance is a trapezoid on the screen, texture coordinates vary non-linearly across it, and interpolating them linearly is what produced the wobbling floors in games of the mid nineties. The fix is to interpolate u/z and v/z along with 1/z and divide at each pixel, which is what “perspective correct” means and why it cost more than the hardware of the day wanted to spend.

Why a texture is not a canvas

Texture here is its own type, with pixels, width and height and no stride. That is a deliberate difference from the canvas, and it says something true: nothing in this program samples a rectangle out of the middle of a larger image, so there is no second width to carry.

It buys a real guarantee. Texture has no set, and its pixels are []const u32, so the direction of travel is in the type rather than in a comment. Passing the destination where the source belongs does not compile.

The alternative, one type used for both ends, is what olive.c does, and it is not wrong: it makes a rendered canvas immediately usable as a texture, which is how render-to-texture works and how a compositor treats a window. The cost is that nothing stops you writing through a handle you meant only to read.

Check yourself

The sampler floors the scaled coordinate to pick a texel. What happens at exactly u = 1.0 with wrapping turned off?

u * 8 is 8, flooring gives 8, and the texture’s last valid index is 7. The clamp catches it and returns texel 7, which is why the branch reads @min(w - 1, tx) and not @min(w, tx).

No pixel in this program actually reaches it. u is exactly 1 only where the weight of the u = 0 corner is exactly 0, which is the line through the other two corners, and for the left square that line is x = 26. Pixel centres sit at half-integers, so they miss it by half a pixel every time.

That is worth knowing rather than reassuring. The guard is never exercised by the picture above, so nothing here would notice if it were wrong, and the snippet you are looking at ships as ReleaseSmall with the bounds check compiled out. Move one vertex to a half-integer and the read goes off the end of the texture: a caught panic in a safety build, and a pixel of whatever follows it in memory in this one.

If you have written C

The rasteriser is the shape you would write in C, with one difference worth naming: @mod rather than %.

C’s % keeps the sign of the dividend, so -1 % 8 is -1, and a texture coordinate that drifts slightly negative indexes backwards out of the array. The symptom is a single wrong row or column along one edge, which is easy to mistake for an off-by-one in the geometry.

Zig does not let you write that by accident, and not because it picks the other answer. % on signed integers is a compile error:

error: remainder division with 'i32' and 'i32': signed integers
and floats must use @rem or @mod

@rem(-1, 8) is -1, matching C. @mod(-1, 8) is 7, taking the sign of the divisor, which is the wrapping a sampler wants. Both exist, neither is the default, and the operator that would have quietly chosen for you is gone.

Next: moving the shape rather than the pixels inside it.