- Zig 97.8%
- Shell 2.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 4m47s
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> |
||
| .forgejo/workflows | ||
| docs | ||
| examples | ||
| scripts | ||
| src | ||
| .devkit.conf | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| CHANGELOG.md | ||
| LICENSE | ||
| README.md | ||
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 forx86_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);
}
}