A linear algebra library for the Zig. Forked from https://github.com/zig-gamedev/zmath
  • Zig 97.8%
  • Shell 2.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker fb2b3c0f74
All checks were successful
test / test (push) Successful in 4m47s
Release v0.2.0
Merge dev into main: the audit of 2026-10-24 (docs/audit-2026-10-24.md).
Breaking: orthographicOffCenter* take bottom before top; min, max, clamp
and saturate propagate NaN (*Num keep the old behaviour); asin and acos
are NaN outside [-1, 1]. CHANGELOG.md's "Unreleased" becomes
"0.2.0 — 2026-10-24".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:12:09 +02:00
.forgejo/workflows CI: run scripts/gate on the urverk forge 2026-10-24 10:44:31 +02:00
docs Gate: build the benchmarks, and test with -Dfma=strict too 2026-10-24 12:52:42 +02:00
examples README: a working, tested example 2026-10-23 11:09:31 +02:00
scripts Gate: build the benchmarks, and test with -Dfma=strict too 2026-10-24 12:52:42 +02:00
src Gate: build the benchmarks, and test with -Dfma=strict too 2026-10-24 12:52:42 +02:00
.devkit.conf Add scripts/gate, and make it devkit's TEST_CMD 2026-10-23 09:50:30 +02:00
.gitignore Fork zmath as Lin 2026-10-23 08:57:15 +02:00
build.zig Gate: build the benchmarks, and test with -Dfma=strict too 2026-10-24 12:52:42 +02:00
build.zig.zon Release v0.2.0 2026-10-24 13:12:09 +02:00
CHANGELOG.md Release v0.2.0 2026-10-24 13:12:09 +02:00
LICENSE Fork zmath as Lin 2026-10-23 08:57:15 +02:00
README.md Gate: build the benchmarks, and test with -Dfma=strict too 2026-10-24 12:52:42 +02:00

Lin

SIMD math library for game developers, written in Zig.

Lin is a fork of zmath by Michal Ziulek and the zig-gamedev contributors. It was forked at upstream commit 4325020 ("Update to Zig 0.17.0", 2026-10-21) and is maintained independently by Urverk. Lin is not affiliated with zig-gamedev, and changes are not sent upstream. zmath's MIT license and copyright notices are kept in LICENSE.

The full zmath git history is kept. git log 4325020.. shows everything Lin has changed.

Changes from zmath

Listed in CHANGELOG.md, from the fork point on.

Development

Lin uses the urverk devkit main/dev model: work happens on dev, and main is advanced with devkit promote / devkit release. Run scripts/setup-tools.sh once to fetch devkit, simgrep and zigmap into .tools/. scripts/gate is the test gate: the tests in ReleaseFast and Debug, with the default FMA mode and with -Dfma=strict, the README example, the benchmarks' build and the x86_64 cross builds, checked beyond zig build's exit code. CI runs it on every push; commit only after it prints gate: ok.

FMA modes

Every function exists in three FMA modes, with identical types, so values pass freely between them:

  • lin.strict.*: a multiply-add is a multiply and an add (two roundings). The same results on every CPU.
  • lin.fused.*: a multiply-add is a fused multiply-add (one rounding). The same results on every CPU with FMA: all arm64, and x86_64 since 2013 (build x86_64 releases for x86_64_v3, or it becomes a slow software fma). Faster and more accurate.
  • lin.relaxed.*: fused where the target CPU has FMA, strict where it does not. Always fast, but results differ between CPUs.

lin.mul and the other top-level names are the default mode: -Dfma=strict|fused|relaxed, fused unless set. For example, a game can keep its simulation in lin.strict and let rendering use the default.

Platforms

Can be used with any graphics API. Lin is developed and tested on AArch64 (macOS). For x86_64, zig build cross (part of scripts/gate) compiles the tests and the example for baseline, v3 and v4 CPUs in ReleaseFast and Debug, but they have not been run there since the fork. Lin has no x86-specific code paths left except the FMA detection behind lin.relaxed. Documentation is in src/root.zig, benchmarks in src/benchmark.zig. The original zmath intro article is here.

Getting started

Add the dependency (main is the stable branch; development happens on dev):

zig fetch --save git+https://git.urverk.org/urverk/lin.git

and in build.zig:

const lin = b.dependency("lin", .{
    .target = target,
    .optimize = optimize,
    // .fma = .strict, // the top-level names' FMA mode; fused if left out
});
exe.root_module.addImport("lin", lin.module("lin"));

Example

This is examples/readme.zig, which zig build test builds and runs; scripts/gate fails if the two differ.

const std = @import("std");
const lin = @import("lin");

pub fn main() void {
    // Lin uses row vectors: a point p is transformed as p * M, and
    // mul(a, b) applies a first, then b.

    // Model matrix: scale, then rotate, then translate.
    const object_to_world = lin.matFromSrt(
        lin.f32x4(2.0, 2.0, 2.0, 0.0), // scale
        lin.quatFromAxisAngle(lin.f32x4(0.0, 1.0, 0.0, 0.0), 0.5), // rotation
        lin.f32x4(0.0, 1.0, 0.0, 0.0), // translation
    );
    const aspect_ratio: f32 = 16.0 / 9.0;

    //
    // OpenGL (right-handed, clip-space z in [-1, 1])
    //
    {
        const world_to_view = lin.lookAtRh(
            lin.f32x4(3.0, 3.0, 3.0, 1.0), // eye position
            lin.f32x4(0.0, 0.0, 0.0, 1.0), // focus point
            lin.f32x4(0.0, 1.0, 0.0, 0.0), // up direction (w = 0: a direction, not a point)
        );
        // Vulkan and Metal want z in [0, 1]: use perspectiveFovRh instead.
        const view_to_clip = lin.perspectiveFovRhGl(0.25 * std.math.pi, aspect_ratio, 0.1, 20.0);
        const object_to_clip = lin.mul(lin.mul(object_to_world, world_to_view), view_to_clip);

        // Lin's matrices are row-major and GLSL's column-major, so let GL
        // transpose on upload:
        //   gl.uniformMatrix4fv(location, 1, gl.TRUE, lin.arrNPtr(&object_to_clip));
        // and in GLSL multiply from the left, as in Lin:
        //   gl_Position = vec4(in_position, 1.0) * object_to_clip;
        const p = lin.mul(lin.f32x4(0.0, 0.0, 0.0, 1.0), object_to_clip);
        std.debug.assert(p[3] > 0.0); // the model's origin is in front of the camera
    }

    //
    // Direct3D (left-handed, clip-space z in [0, 1])
    //
    {
        const world_to_view = lin.lookAtLh(
            lin.f32x4(3.0, 3.0, -3.0, 1.0),
            lin.f32x4(0.0, 0.0, 0.0, 1.0),
            lin.f32x4(0.0, 1.0, 0.0, 0.0),
        );
        const view_to_clip = lin.perspectiveFovLh(0.25 * std.math.pi, aspect_ratio, 0.1, 20.0);
        const object_to_clip = lin.mul(lin.mul(object_to_world, world_to_view), view_to_clip);

        // HLSL packs matrices column-major by default: upload the transpose
        //   lin.storeMat(upload_memory, lin.transpose(object_to_clip));
        // and in HLSL: out_position = mul(float4(in_position, 1.0), object_to_clip);
        var upload_memory: [16]f32 = undefined;
        lin.storeMat(&upload_memory, lin.transpose(object_to_clip));
    }

    //
    // WASD camera movement (left-handed: +z is forward)
    //
    {
        const pitch: f32 = 0.1;
        const yaw: f32 = 0.3;
        const delta_time: f32 = 1.0 / 60.0;
        const speed = lin.f32x4s(10.0 * delta_time);

        const transform = lin.mul(lin.rotationX(pitch), lin.rotationY(yaw));
        const forward = lin.normalize3(lin.mul(lin.f32x4(0.0, 0.0, 1.0, 0.0), transform));
        // normalize3 of a zero vector is NaN, on purpose; where "no direction"
        // is a valid answer, say so with normalizeOrZero3.
        const right = lin.normalize3(lin.cross3(lin.f32x4(0.0, 1.0, 0.0, 0.0), forward));

        var cam_pos = lin.loadArr3(.{ 0.0, 1.5, -5.0 });
        const w_down, const d_down = .{ true, false }; // from your input system
        if (w_down) cam_pos += speed * forward;
        if (d_down) cam_pos += speed * right;

        var position: [3]f32 = undefined;
        lin.storeArr3(&position, cam_pos);
    }

    //
    // FMA modes: lin.* is the build's default mode (fused unless -Dfma says
    // otherwise); lin.strict gives the same bits on every CPU, FMA or not.
    //
    {
        const a = lin.rotationZ(0.25);
        const b = lin.translation(1.0, 2.0, 0.0);
        const render = lin.mul(a, b); // default mode
        const simulation = lin.strict.mul(a, b); // same types, any mode
        std.debug.assert(lin.isNearEqual(render[3], simulation[3], lin.f32x4s(1e-6))[0]);
    }

    //
    // SIMD over many values: the wave y = sin(sqrt(x^2 + z^2) - t), eight
    // points at a time (the same code works with F32x4 and F32x16).
    //
    {
        const T = lin.F32x8;
        const grid_size = 64;
        const scale: f32 = 0.1;
        const time: f32 = 0.5;
        var ys: [grid_size * grid_size]f32 = undefined;

        var z_index: usize = 0;
        while (z_index < grid_size) : (z_index += 1) {
            const z = scale * (@as(f32, @floatFromInt(z_index)) - grid_size / 2);
            const vz = lin.splat(T, z);

            var x_index: usize = 0;
            while (x_index < grid_size) : (x_index += lin.veclen(T)) {
                const x = scale * (@as(f32, @floatFromInt(x_index)) - grid_size / 2);
                const vx = lin.splat(T, x) + lin.f32x8(0, 1, 2, 3, 4, 5, 6, 7) * lin.splat(T, scale);

                const vy = lin.sin(lin.sqrt(vx * vx + vz * vz) - lin.splat(T, time));
                lin.store(ys[x_index + z_index * grid_size ..], vy, 0);
            }
        }
        std.debug.assert(@abs(ys[0]) <= 1.0);
    }
}