Some time well over a year ago, I did Raytracing in One Weekend so I could learn Zig. It’s a neat book, I recommend it if you haven’t tried it. (See also tinyraycaster and Ray Tracer Construction Kit for less hand holding).
RTIOW is a project book, providing C++ code snippets for each step along the way. I’ve never done any real work in C++, and I hope I never will. The fun part about the book being written in C++ is translating it into Zig (which I did poorly, I was still new to Zig, and based off feedback in #zig IRC I still have a lot to learn lol). In this post I’m going to compare some of the book’s C++ snippets to my own Zig translations, highlighting where I think Zig shines and where it doesn’t.
The repo is here btw: https://git.sr.ht/~allidoisclassic/zigtracer. You can send me an email if you think there’s any particularly awful code in there. But I want to emphasize I wrote it in Zig 0.14, halfway migrated to 0.15.1, and did another quick n dirty migration again to 0.16 just last week. It’s not the prettiest Zig code, and I refuse to make any promises about cleaning it up.
Right away RTIOW defines a vec3 class with with basic vector math and operator overloading so you can easily do things like u * v and cross(u, v), etc. Zig doesn’t have classes or operator overloading. It does have Vectors built into the language, and they come with support for arithmetical operators.
In short, all of this boilerplate:
inline vec3 operator+(const vec3& u, const vec3& v) {
return vec3(u.e[0] + v.e[0], u.e[1] + v.e[1], u.e[2] + v.e[2]);
}
inline vec3 operator-(const vec3& u, const vec3& v) {
return vec3(u.e[0] - v.e[0], u.e[1] - v.e[1], u.e[2] - v.e[2]);
}
inline vec3 operator*(const vec3& u, const vec3& v) {
return vec3(u.e[0] * v.e[0], u.e[1] * v.e[1], u.e[2] * v.e[2]);
}
inline vec3 operator*(double t, const vec3& v) {
return vec3(t*v.e[0], t*v.e[1], t*v.e[2]);
}
inline vec3 operator*(const vec3& v, double t) {
return t * v;
}
inline vec3 operator/(const vec3& v, double t) {
return (1/t) * v;
}
is given for free in Zig just with:
const Vec3 = @Vector(3, f64);
However, Vectors are intended to be used for SIMD, not for vector math. LLVM’s autovectorization optimizations on regular arrays are pretty good, and using built-in Vectors strictly enforces SIMD usage at all times, even when it may hurt performance. I don’t care. Being able to use arithmetical operators is higher priority because I find it more readable. When I complete all 3 RT books I might profile array vs vecotr implementations to see if there’s a significant difference. My gut says no.
Vectors come with their own annoyances, I can’t trivially scalars to multiply or divide Vectors. I have to create a 3D vector of the scalar and multiply the two vectors together, which Zig provides @splat for, but I don’t really like it. I end up with lines like this:
self.pixel_delta_u = viewport_u / @as(Vec3, @splat(w)));
self.pixel_delta_v = viewport_v / @as(Vec3, @splat(h));
In my perfect world that snippet would be:
self.pixel_delta_u = viewport_u / w;
self.pixel_delta_v = viewport_v / h;
I know that has an implicit cast of a scalar to a vector, and Zig emphasizes explicitness and correctness, and probably doesn’t play nice with the SIMD instructions, but it’s just so much easier on the eyes. I’ll live with it for now.
The biggest pain point for Zig on this project is math with ints and floats. There are no implicit casts between floats and ints, so I end up with @as(f64, @floatFromInt(x)) or @as(usize, @trunc(y)) everywhere, which again I just find cumbersome. I’m making this more difficult than it needs to be, I haven’t put much time into trying smooth it over. Adding constants like const x_f64: f64 = @floatFromInt(x) where I need them is probably enough to address it. Anyway here’s a snippet showing my dark, twisted reality:
const viewport_upper_left: Vec3 = self.center - (@as(Vec3, @splat(self.focus_distance)) * self.w) - viewport_u / @as(Vec3, @splat(2.0)) - viewport_v / @as(Vec3, @splat(2.0));
self.pixel_origin = viewport_upper_left + @as(Vec3, @splat(0.5)) * (self.pixel_delta_u + self.pixel_delta_v);
Zig shines brighter in other areas like using tuples for multiple return values, and optionals. In C++ it’s typical to modify parameters via pointers and return a boolean indicating whether anything was modified. From what I can tell (not a C++ expert) it’s more common when there’s multiple values you want to return. Something like this:
bool scatter(const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered)
const override {
attenuation = color(1.0, 1.0, 1.0);
scattered = /* snippy snip */;
return true;
}
Translated to Zig, using an optional tuple of Ray and Vec3 for the return type:
pub fn scatter(self: Dielectric, io: std.Io, r_in: Ray, rec: HitRecord) ?struct { Ray, Vec3 } {
const ri: f64 = if (rec.front_face) 1.0 / self.refraction_index else self.refraction_index;
const scattered = Ray{ .origin = /* snip */, .direction = /* snip */ };
return .{ scattered, Vec3{ 1, 1, 1 } };
}
The call site is cleaner in comparison:
ray scattered;
color attenuation;
if (rec.mat->scatter(r, rec, attenuation, scattered))
return attenuation * ray_color(scattered, depth-1, world);
if (rec.mat.scatter(io, self, rec)) |value| {
const scattered: Ray, const attenuation: Vec3 = value;
return attenuation * scattered.color(io, depth - 1, world);
}
I really don’t like modifying parameters of a function because it’s not always clear to the caller. It just seems like bad manners. Returning a tuple and destructuring it to scattered and attenuation is much more explicit.
Another big difference is obviously that Zig is not an object-oriented language. There are structs, there are methods, but there are no classes. RTIOW uses an abstract class, material, to encapsulate behavior unique to each type of material (lambertian, metal, dielectric). Then each material is implemented in its own class, inheriting from material.
class material {
public:
virtual ~material() = default;
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const {
return false;
}
};
class lambertian : public material {
// ...
};
class metal : public material {
// ...
};
class dielectric : public material {
// ...
};
From my (limited) experience, when you are in control of all possible types (in this case, the materials), using static dispatch with unions is the simplest way to implement polymorphism in Zig:
pub const Material = union(enum) {
lambertian: Lambertian,
metal: Metal,
dielectric: Dielectric,
pub fn scatter(self: Material, io: std.Io, r_in: Ray, rec: HitRecord) ?struct { Ray, Vec3 } {
return switch (self) {
inline else => |mat| mat.scatter(io, r_in, rec),
};
}
pub const Lambertian = struct {
albedo: Vec3,
pub fn scatter(self: Lambertian, io: std.Io, r_in: Ray, rec: HitRecord) ?struct { Ray, Vec3 } {
// ...
}
};
pub const Metal = struct {
albedo: Vec3,
fuzz: f64,
pub fn scatter(self: Metal, io: std.Io, r_in: Ray, rec: HitRecord) ?struct { Ray, Vec3 } {
// ...
}
};
pub const Dielectric = struct {
refraction_index: f64,
pub fn scatter(self: Dielectric, io: std.Io, r_in: Ray, rec: HitRecord) ?struct { Ray, Vec3 } {
// ...
}
};
};
I think this static dispatch pattern is fairly simple, and shouldn’t be that crazy to anyone familiar with C++ classes. I’m more accustomed to Go interfaces and wrapped my head around this just fine. For other scenarios where static dispatch isn’t feasible, I recommend reading Zig Interfaces.
Anyway, despite some growing pains around casting, I think using Zig for RTIOW is a step up from how it’s written in C++. If you’re a C++ diehard and you feel offended by that, please send any and all thoughts to /dev/null.
Here’s my final render:

The more eagle-eyed readers among you might spot that this image is slightly skewed. That’s because there’s a bug in my Camera.zig render implementation, which despite rewriting three times I cannot identify the cause of. If you read the code and something jumps out at you, please send a patch or email. I’m going to leave it like this while I proceed through the rest of the series.
Thanks for reading :)