Zig pathtracer

If you’ve been reading this blog for a bit, you might know that when I experiment with new programming languages, I like to write a simple pathtracer, to get a better “feel”. It’s very much based on smallpt (99-line pathtracer in C++), but I do not want to make it as short as possible. I am more interested in how easily I can get it to run and what language ‘features’ I can use. To be fair, not all languages are made with pathtracers in mind, so some of them make it easier than others. In the past I have tried Go (2013) and Rust (2014). Go is probably an example of language that’s not 100% suited for this kind of a problem, but I had lots of fun, nonetheless.

This time I decided to try Zig. I’ve to admit I went in completely ‘blind’, didn’t know much about the language other than some passing remarks posted on Twitter. I kinda expected another Rust, so initially was a bit put off by how “Spartan” Zig was. After I adjusted and started taking it for what it was – a modern “C+” alternative – it actually became a very fun experiment. One thing I liked was that - similar to Go - there is usually just one way of doing things, so you don’t spend too much time thinking about it, the decision has been already made for you… Sometimes it can be a bit surprising (“what do you mean the only way to iterate using indices is a ‘while’ loop, ‘for’ can’t do it?“), but ultimately I think it’s a a good thing.

Zig is a young language that is still evolving fairly rapidly, so there are differences between versions, I was using a nightly build, 0.9.0-dev.473+9979741bf to be more specific (it’ll not compile on 0.8.0). Github project can be found here.

As usual, let me share a bunch of random observation. As usual, please keep in mind, these are all written from a perspective of someone who does not know much about language design and is just fooling around. It might sound like I’m mostly complaining, but I’ve actually grown to like Zig a lot.

  • as mentioned, Zig is somewhat minimalistic (reminds me a little bit of Go in that respect). In their own words, it is a “small, simple language”. They definitely do not like creating too many keywords, so expect to use ‘const’ a lot :).
      // An actual const value
      const RESOLUTION: usize = 512;
      // Import module/using
      const std = @import("std");
      // Typedef
      const rfloat = f64;
      const Vec4 = Vector(4, rfloat);
      // Enum definition
      const MaterialType = enum { DIFFUSE, GLOSSY, MIRROR };
      // Struct definition
      const Axes = struct { a: Vec4, b: Vec4 };
      // ... and so on.
      
  • “mutability” is explicit, you either use var (for variable) or const (for a constant) (so no ‘implied’ const like in Rust, but conversion was fairly straightforward, let -> const, let mut -> var)
  • Zig supports “dummy” pointers, but they can’t be null, unless explicitly marked as “optional”
  • “optional” support is not limited to pointers, I used it to signal if we hit any object when raycasting:
      const IntersectResult = struct {
        objectIndex: ?usize = undefined, 
        t: rfloat = std.math.f64_max
      };
      const shadow_result = scene.intersect(shadow_ray);
      // Test if object index is "defined". |shadow_index| will contain an actual value
      // that can be used (we have to "extract" it from optional type).
      if (shadow_result.objectIndex) |shadow_index| {
        if (shadow_index == light_index) {
      
  • no operator overloading, but…
  • built-in SIMD vector types. I used it mostly to get around the operator thing (they come with all the basic operations, but not stuff like dot product), but it’s really nice to have it right off the bat. I used 4D vectors even though I only needed xyz, because it’s somehow more ‘natural’ to me (maps to m128), but it seems like 3D vectors would have worked just as well (based on my limited tests in Compiler Explorer.
  • function parameters might be passed by reference even if not explicitly requested (well, would be pointer if explicit). Docs claim “When these types are passed as parameters, Zig may choose to copy and pass by value, or pass by reference, whichever way Zig decides will be faster”. I was a bit skeptical at first, but after verifying it in a few random places, I stopped worrying and just trusted the compiler to do the right thing.
  • optimization mentioned above “is made possible, in part, by the fact that parameters are immutable”. I was a tiny bit annoyed by this at first, but it’s a very minor thing.
  • for loop can only be used to iterate over elements of an array, if you want to just execute a block N times, you need while
      var i: usize = 0;
      while (i < SPP) : (i += 1) {  // loop for (0..SPP)
      
    This one is a little bit weird, but I’d take it easier if there was a way to have it as a one-liner (ie. fold counter definition and while)
  • I might be spoiled by Rust, which has amazing error messages, but compiler messages are a bit terse and sometimes misleading. Some of my parameters were originally called u1/u2 which shadows Zig types (1-bit/2-bit integer), but the error message on nightly is ‘unused function parameter’. I could not figure it out until I used Compiler Explorer to verify whether I am really not touching that argument and noticed that 0.8.0 gives a much better message. (Reported the issue here). There were some other cases that caused a few moments of head scratching.
  • I feel like compiler could use some more strict warnings, too… This one is on me, but I feel like I could use a bit of help :) My first version of intersect function would return an object pointer (if hit). Inside, I was iterating over all objects by value. Please refer to this simplified CE snippet for details. My initial version is intersect1 + bar . Look for LBB0_8 to see how the loop behaves, as you might notice, it’s an index-based loop, it never actually stores the object pointer anywhere, so the value returned from that function will be bogus. This is fair and I guess could result in a faster code sometimes (not sure about this particular case), however, ideally, we’d at least get a warning about taking a pointer to a temporary/optimized-out data. For comparison, see intersect2 + foo, iterating over pointers, corresponding loop snippet is LBB1_8.
  • Random minor personal annoyances, ‘it’s not you it’s me’ territory:
    • floats are printed in a scientific format by default. To get decimal you have to use {d}, which is fine, but a bit more annoying if you want to printed Vectors that way, have to provide own formatters, like:
              fn format_vector(ns: Vec4, comptime fmt: []const u8, 
                  options: std.fmt.FormatOptions, writer: anytype) !void {
                  _ = options;
                  _ = fmt;
                  return std.fmt.format(writer, 
                      "[{d},{d},{d},{d}]", .{ ns[0], ns[1], ns[2], ns[3] });
              }      
            
      I was hoping there’d be an option to change it globally, but it doesn’t seem like it.
    • Zig float literals have type comptime_float, which is essentially the largest type, ie. f128. To use a different type you have to cast it first and I’m not loving the syntax:
            // tan is not implemented for comptime_float (55.0 itself is a comptime_float)
            // so we have to explicitly cast ourselves.
            const fov_scale = std.math.tan(@as(rfloat, 55.0 * std.math.pi / 180.0 * 0.5));
            
      I would not mind C’s way of doing 55.0f (float) vs 55.0 (double) and so on, but admittedly not sure how to handle f32/f64 and f128, would have to come up with an extra suffix. It is also a bit inconsistent, as some functions (like pow) will take a type parameter and allow for comptime params:
            const x = std.math.pow(f32, 45.0, 2.0); // No need for @as(f32, 45.0)
            

Zig source code is a bit shorter than both Go & Rust (around 670 lines for Zig - ~770 for Go & Rust), I’m guessing mostly because of a built-in Vector type. Generated code is quite impressive, too. Binary is small - around 120k, around 200k for Rust and 1.6MB for Go(!). As far as performance goes, Zig is pretty much on par with Rust or possibly a bit faster. Take it with a grain of salt as it’s been measured on my laptop, but I made sure it was properly warmed up and not throttling. Both Zig & Rust take around 400 seconds to render a 512x512 image (16*16 samples per pixel, up to 8 bounces (min 4), 4xAA). Multi-threaded, both versions need about 100 seconds. If I squint hard, it seems like maybe Zig is consistently faster by 2-3%, but it’s not exactly apples to apples, as we’re using 2 different threading systems. I would not draw any far reaching conclusions other than stating that Zig is at least in the same ballpark and very competitive.

Rendered image

More Reading