⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.246+05671ced9What's newOn an older Zig?Blog

A Native Window

Updated

The studio on the first page is the engine in a browser.

The same engine also runs in a desktop window. The window is drawn by raylib, a small C library for windows, graphics and input.

The front end is one file, native.zig. Like the browser front end, it reads input, calls the engine, and copies the changed rectangle to the screen.

cd examples/paint
./fetch-raylib.sh      # once: download raylib's C at a pinned commit
zig build run          # open the window

raylib as C, not as a package

raylib is used as a folder of C files, compiled by our own build.zig.

fetch-raylib.sh downloads them at a fixed commit, the same one the Lane Dodger game uses. That chapter explains why the C is vendored rather than used as a Zig package: a package’s build script must keep up with Zig master, and raylib’s often does not.

Here is the whole raylib build:

examples/paint/build.zigraylibModule
fn raylibModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.lang.Optimize) *std.Build.Module {
    const mod = b.createModule(.{
        .target = target,
        .optimize = optimize,
        .link_libc = true,
        // None of this C is ours, and UBSan's runtime would only report on
        // a pinned upstream release.
        .sanitize_c = .off,
    });
    const lib = b.addLibrary(.{ .name = "raylib", .linkage = .static, .root_module = mod });

    inline for (.{
        .{ "_GNU_SOURCE", "" },
        .{ "GL_SILENCE_DEPRECATION", "199309L" },
        .{ "PLATFORM_DESKTOP_GLFW", "" },
        .{ "GRAPHICS_API_OPENGL_33", "" },
        .{ "SUPPORT_MODULE_RSHAPES", "1" },
        .{ "SUPPORT_MODULE_RTEXTURES", "1" },
        .{ "SUPPORT_MODULE_RTEXT", "1" },
        // A paint program makes no sound and loads no 3D models.
        .{ "SUPPORT_MODULE_RAUDIO", "0" },
        .{ "SUPPORT_MODULE_RMODELS", "0" },
    }) |macro| mod.addCMacro(macro[0], macro[1]);

    const windows = target.result.os.tag == .windows;
    mod.addCMacro(if (windows) "_GLFW_WIN32" else "_GLFW_X11", "");
    mod.addIncludePath(b.path(raylib_root ++ "/platforms"));
    mod.addIncludePath(b.path(raylib_root ++ "/external/glfw/include"));
    mod.addCSourceFiles(.{
        .root = b.path(raylib_root),
        .files = &.{ "rcore.c", "rshapes.c", "rtextures.c", "rtext.c", "rglfw.c" },
        .flags = &.{"-std=gnu99"},
    });
    const libs: []const []const u8 = if (windows)
        &.{ "opengl32", "gdi32", "winmm", "user32", "shell32" }
    else
        &.{ "GL", "X11", "Xrandr", "Xinerama", "Xi", "Xcursor" };
    for (libs) |name| mod.linkSystemLibrary(name, .{});

    const translated = b.addTranslateC(.{
        .root_source_file = b.path(raylib_root ++ "/raylib.h"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });
    const rl = translated.createModule();
    rl.linkLibrary(lib);
    return rl;
}

addTranslateC reads raylib.h and turns it into a Zig module. The front end then calls rl.InitWindow, rl.DrawTexture and the rest as if they were Zig functions.

A paint program makes no sound and loads no 3D models, so the audio and model modules are switched off with two macros. That leaves five C files.

The front end

The window shows a texture the size of the document. Each frame, the loop reads the mouse, calls the engine, and uploads only the rectangle render says changed:

examples/paint/src/native.zigmain
pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    var doc: engine.Document = try .init(gpa, width, height, engine.default_layers, engine.default_history_budget);
    defer doc.deinit();

    const headless = build_options.frames != null;
    if (headless) {
        rl.SetTraceLogLevel(rl.LOG_WARNING);
        // A smoke run draws the engine's demo picture, so CI checks a
        // window can show what the tests drew.
        try engine.demo.draw(&doc);
    }

    rl.InitWindow(width, height + bar, "Paint");
    defer rl.CloseWindow();
    rl.SetTargetFPS(60);

    // The texture is the size of the document and starts as a copy of the
    // display. After that only changed rectangles are uploaded.
    _ = doc.render();
    const texture = rl.LoadTextureFromImage(.{
        .data = doc.display.ptr,
        .width = width,
        .height = height,
        .mipmaps = 1,
        .format = rl.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
    });
    defer rl.UnloadTexture(texture);

    // Room to copy one changed rectangle into, row by row: a texture update
    // needs the rectangle's pixels next to each other.
    const scratch = try gpa.alloc(engine.Rgba, width * height);
    defer gpa.free(scratch);

    var preset: usize = 0;
    var color: usize = 0;
    var size: f32 = 8;
    var tool: Tool = .paint;
    var seed: u32 = 1;
    var drawing = false;
    var frame: usize = 0;
    var message: [:0]const u8 = "";

    while (!rl.WindowShouldClose()) : (frame += 1) {
        if (build_options.frames) |n| if (frame >= n) break;

        // ---- keyboard --------------------------------------------------------
        const ctrl = rl.IsKeyDown(rl.KEY_LEFT_CONTROL) or rl.IsKeyDown(rl.KEY_RIGHT_CONTROL);
        if (!drawing) {
            if (ctrl and rl.IsKeyPressed(rl.KEY_Z)) _ = doc.undo();
            if (ctrl and rl.IsKeyPressed(rl.KEY_Y)) _ = doc.redo();
            if (ctrl and rl.IsKeyPressed(rl.KEY_S)) {
                message = if (save(init.io, &doc)) "Saved painting.png" else |_| "Saving failed";
            }
            if (!ctrl) {
                for (0..presets.len) |i| {
                    if (rl.IsKeyPressed(rl.KEY_ONE + @as(c_int, @intCast(i)))) preset = i;
                }
                if (rl.IsKeyPressed(rl.KEY_P)) tool = .paint;
                if (rl.IsKeyPressed(rl.KEY_E)) tool = .erase;
                if (rl.IsKeyPressed(rl.KEY_F)) tool = .fill;
                if (rl.IsKeyPressed(rl.KEY_C)) color = (color + 1) % colors.len;
                if (rl.IsKeyPressed(rl.KEY_LEFT_BRACKET)) size = @max(1, size - 2);
                if (rl.IsKeyPressed(rl.KEY_RIGHT_BRACKET)) size = @min(80, size + 2);
                if (rl.IsKeyPressed(rl.KEY_L)) _ = doc.setActiveLayer(1 - doc.active_layer);
            }
        }

        // ---- mouse -------------------------------------------------------------
        // A mouse has no pressure, and the browser reports 0.5 for one, so
        // the native build does the same and both draw alike.
        const m = rl.GetMousePosition();
        const p: engine.Point = .{ .x = m.x, .y = m.y, .pressure = 0.5 };
        if (rl.IsMouseButtonPressed(rl.MOUSE_BUTTON_LEFT) and m.y < height) {
            switch (tool) {
                .fill => _ = doc.fillAt(@intFromFloat(@max(m.x, 0)), @intFromFloat(@max(m.y, 0)), colors[color]) catch false,
                .paint, .erase => {
                    var brush = presets[preset].brush;
                    brush.radius = size;
                    brush.color = colors[color];
                    brush.seed = seed;
                    doc.beginStroke(brush, if (tool == .erase) .eraser else .brush, p);
                    drawing = true;
                },
            }
        } else if (drawing and rl.IsMouseButtonDown(rl.MOUSE_BUTTON_LEFT)) {
            doc.moveStroke(p);
        } else if (drawing) {
            doc.endStroke();
            drawing = false;
            seed += 1;
        }
        if (drawing and !rl.IsWindowFocused()) {
            // Focus lost halfway through: drop the stroke, as the browser does.
            doc.cancelStroke();
            drawing = false;
        }

        // ---- picture -----------------------------------------------------------
        const r = doc.render();
        if (!r.isEmpty()) {
            var i: usize = 0;
            var y = r.y0;
            while (y < r.y1) : (y += 1) {
                const row = @as(usize, y) * width;
                @memcpy(scratch[i..][0..r.width()], doc.display[row + r.x0 .. row + r.x1]);
                i += r.width();
            }
            rl.UpdateTextureRec(texture, .{
                .x = @floatFromInt(r.x0),
                .y = @floatFromInt(r.y0),
                .width = @floatFromInt(r.width()),
                .height = @floatFromInt(r.height()),
            }, scratch.ptr);
        }

        rl.BeginDrawing();
        rl.ClearBackground(.{ .r = 40, .g = 42, .b = 54, .a = 255 });
        rl.DrawTexture(texture, 0, 0, rl.WHITE);
        var status_buf: [160]u8 = undefined;
        const status = std.mem.printSentinel(&status_buf, "{s}  {s}  size {d}  {s} layer   1-9 brush  P/E/F  [ ]  C colour  L layer  Ctrl+Z/Y  Ctrl+S   {s}", .{
            presets[preset].name,
            @tagName(tool),
            @as(u32, @intFromFloat(size)),
            if (doc.active_layer == 0) "bottom" else "top",
            message,
        }, 0) catch "";
        const c = colors[color];
        rl.DrawRectangle(6, height + 6, 16, 16, .{ .r = c[0], .g = c[1], .b = c[2], .a = 255 });
        rl.DrawText(status.ptr, 28, height + 8, 12, .{ .r = 220, .g = 222, .b = 230, .a = 255 });
        rl.EndDrawing();
    }

    if (build_options.screenshot) |path| {
        // raylib wants a C string, ending in a zero byte.
        var path_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;
        rl.TakeScreenshot((try std.mem.printSentinel(&path_buf, "{s}", .{path}, 0)).ptr);
    }
}

raylib’s texture update wants the rectangle’s pixels next to each other in memory. In display they are not: each row of the rectangle is one row of a 640-pixel image. So the rows are copied into scratch first.

A mouse has no pressure, so every point gets 0.5, the value browsers report for a mouse. The window and the studio draw the same stroke for the same movement.

Cross-compiling

Zig compiles for other operating systems with one flag:

zig build native -Dtarget=x86_64-windows

On Linux, that builds a working paint.exe of about 1.3 MB, and nothing needs to be installed first.

It works because of what raylib needs from Windows: opengl32, gdi32, winmm, user32 and shell32. Zig ships the import libraries for these, from the MinGW project, inside the compiler. The build.zig above only has to choose _GLFW_WIN32 instead of _GLFW_X11 and name those libraries.

One more line matters on Windows:

if (target.result.os.tag == .windows) exe.subsystem = .windows;

A Windows program is either a console program or a window program. A console program opens a terminal window when you double-click it, behind the paint window. .windows asks for a window program.

CI builds the Windows .exe on every run, from a Linux machine, so a change in Zig or raylib that breaks it fails the nightly build. CI does not run it, because it has no Windows machine to run it on.

Where cross-compiling stops

The other direction is harder for Linux and simply blocked for macOS.

Building for Linux on another CPU, such as a 64-bit ARM board, needs the X11 header files for that system. Zig does not ship them, so they have to come from that system. They are only headers, so it can be done, but it is not one flag.

Building for macOS from Linux needs Apple’s SDK: the headers and libraries for Cocoa, IOKit and OpenGL that raylib uses on a Mac. Zig does not ship them, and Apple’s licence does not allow anyone else to ship them either. The compiler could do the job. The files it would need are not allowed to be copied.

So the macOS build is made on a Mac, where the SDK is already installed. GitHub provides Mac machines for CI, so this is a cost, not a dead end.

A smoke test without a screen

CI has no screen. It runs the window inside Xvfb, a virtual display that draws into memory.

-Dframes=30 makes the program draw the engine’s demo picture, show it for 30 frames and exit. -Dshot saves the last frame:

zig build native -Dframes=30 -Dshot=demo-native.png
xvfb-run ./zig-out/bin/paint

Compiling proves the raylib calls still match the headers. Running proves the window opens and the texture is drawn.

Explain before reading the answer

The browser studio and the native window both send the engine the same points for the same movement.

Will they draw exactly the same pixels?

The engine will. Given the same calls, it computes the same picture on any machine. The first chapter’s demo check proves this for the browser and native builds.

What can differ is the calls. A browser can report more samples per second than one per frame, and the studio passes all of them on. raylib reads the mouse once per frame. So a fast stroke gets more points in the browser. The spacing from the Strokes chapter keeps the dabs evenly spaced either way, but where the path bends between samples, the two paths are not quite the same.