Let's make a Ray Tracer (in Go)

because why not?

Let's make a Ray Tracer (in Go)

When you build systems from scratch, you get to control everything, from how fast it runs to how it looks in the end. That’s what systems development is all about. It’s not just about coding—it’s about knowing how things work behind the scenes and bringing them to life. That’s where Go comes in—it's a fast, simple, and efficient language that handles both complex tasks and lower-level ones, like building a ray tracer. In this blog, we'll walk through the first steps of building one, taking inspiration from Peter Shirley’s famous Ray Tracing in One Weekend and adapting it for Go.

For this tutorial, you'll need a basic understanding of physics and math—mainly vectors, which you likely remember from your JEE days—as well as some programming experience. Peter Shirley’s guide was written in C++, but the concepts are universal and can be implemented in any language, like Go.

But, what exactly is a ray tracer?

Nvidia's RTX Ray Tracing Brings Minecraft To Life

Gamers know ray tracing for its realistic lighting in games like Minecraft, making shadows, reflections, and textures look more lifelike. But what is it? Ray tracing is a method that simulates how light moves and interacts with objects, creating more natural-looking scenes by following light rays and how they reflect and bounce.

Setup

Before we start coding the ray tracer, let’s get the basic setup done. In a new folder, initialise a new Go Module:

mkdir raytracer && cd raytracer
go mod init raytracer

Now, we need a way to save our pixel data. You’re probably familiar with formats like JPG and PNG, which are great for reducing file size but come with added complexity. Since our goal isn’t to build a PNG encoder, we’ll use something simpler: the PPM format.
The PPM format encodes the image data in plain text format, which makes creating images in it pretty simple.

Also make sure to install a PPM File viewer, you can find many on the internet, but I use the VS Code Extension: PBM/PPM/PGM Viewer for Visual Studio Code.

Now let’s start writing code🚀

Writing a PPM File Encoder

We’ll first create a function to create a PPM Image, for now we’ll output a single color

package main

import (
    "fmt"
    "os"
)

func writePPM() {
    width := 256
    height := 256
    outputFile := "output.ppm"

    file, err := os.Create(outputFile)
    if err != nil {
        panic(err)
    }
    defer file.Close() // do you know what defer is used for?

    // save the header information
    fmt.Fprintf(file, "P3\n%d %d\n255\n", width, height)
    // loop over each pixel, and set it's value
    for j := 0; j < height; j++ {
        for i := 0; i < width; i++ {
            r := 141
            g := 71
            b := 124
            fmt.Fprintf(file, "%d %d %d\n", r, g, b)
        }
    }
}

func main() {
    writePPM()
}

It should output a purple square, something like this:

Sweet! Now I’d like you guys to modify the code and experiment with making a couple of designs. As an assignment, you can try creating a simple chessboard pattern. Use the pixel positions to alternate between two colors like black and white.

Calibration Checkerboard Collection | Mark Hedley Jones

This exercise is key to understanding how ray tracing works at its core—changing pixel colors based on certain conditions. In this case, you’ll be alternating the colors based on the position of each pixel. Give it a shot!

Creating the Background

We will start by creating a structure to represent points in the 3-D space, we’ll call it Vec3, and also a struct Ray, to represent rays

type Vec3 struct {
    X, Y, Z float64
}

type Ray struct {
    Origin    Vec3
    Direction Vec3
}

As you can see the Vec3 struct contains the X,Y,Z positions. Now you can write the methods to add, multiply, and normalise the vector.

func (v Vec3) Add(u Vec3) Vec3 {
    return Vec3{v.X + u.X, v.Y + u.Y, v.Z + u.Z}
}

func (v Vec3) Mul(t float64) Vec3 {
    return Vec3{v.X * t, v.Y * t, v.Z * t}
}

func (v Vec3) Normalize() Vec3 {
    length := math.Sqrt(v.X*v.X + v.Y*v.Y + v.Z*v.Z)
    return Vec3{v.X / length, v.Y / length, v.Z / length}
}

Now update the writePPM function, to create a gradient

func writePPM() {
    // image dimensions
    width := 400
    height := 200

    // camera setup
    lowerLeftCorner := Vec3{-2.0, -1.0, -1.0}
    horizontal := Vec3{4.0, 0.0, 0.0}
    vertical := Vec3{0.0, 2.0, 0.0}
    origin := Vec3{0.0, 0.0, 0.0}

    file, err := os.Create("output.ppm")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    fmt.Fprintf(file, "P3\n%d %d\n255\n", width, height)

    for j := height - 1; j >= 0; j-- {
        for i := 0; i < width; i++ {
            u := float64(i) / float64(width)
            v := float64(j) / float64(height)

            // it will be white at the bottom and purple at the top
            color := Vec3{1.0, 1.0, 1.0}.Mul(1.0 - v).Add(Vec3{0.5, 0.7, 1.0}.Mul(v))

            ir := int(255.99 * color.X)
            ig := int(255.99 * color.Y)
            ib := int(255.99 * color.Z)

            fmt.Fprintf(file, "%d %d %d\n", ir, ig, ib)
        }
    }
}

func main() {
    writePPM()
}

And now you should have a gradient looking like this:

Now let’s render the first object, let’s create a simple Sphere, we’ll make a hitSphere function to determine if a rays hits a given sphere:

func hitSphere(center Vec3, radius float64, r Ray) float64 {
    oc := r.Origin.Sub(center)
    a := r.Direction.Dot(r.Direction)
    b := 2.0 * oc.Dot(r.Direction)
    c := oc.Dot(oc) - radius*radius
    discriminant := b*b - 4*a*c

    if discriminant < 0 {
        return -1.0
    } else {
        return (-b - math.Sqrt(discriminant)) / (2.0 * a)
    }
}

Let’s create a function which determines the color of pixel where each ray intersects:

func rayColor(r Ray) Vec3 {
    center := Vec3{0, 0, -1}
    t := hitSphere(center, 0.5, r)
    if t > 0.0 {
        N := r.At(t).Sub(center).Normalize()
        return Vec3{N.X + 1, N.Y + 1, N.Z + 1}.Mul(0.5) // map from [-1,1] to [0,1]
    }
    // Background color (gradient from white to blue)
    unitDirection := r.Direction.Normalize()
    t = 0.5 * (unitDirection.Y + 1.0)
    return Vec3{1.0, 1.0, 1.0}.Mul(1.0 - t).Add(Vec3{0.5, 0.7, 1.0}.Mul(t))
}

Now let’s update the writePPM function to use the new rayColor function:

func writePPM() {
    // image dimensions
    width := 400
    height := 200

    // camera setup
    lowerLeftCorner := Vec3{-2.0, -1.0, -1.0}
    horizontal := Vec3{4.0, 0.0, 0.0}
    vertical := Vec3{0.0, 2.0, 0.0}
    origin := Vec3{0.0, 0.0, 0.0}

    file, err := os.Create("output.ppm")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    fmt.Fprintf(file, "P3\n%d %d\n255\n", width, height)

    for j := height - 1; j >= 0; j-- {
        for i := 0; i < width; i++ {
            u := float64(i) / float64(width)
            v := float64(j) / float64(height)

            // each ray starts at the camera origin and goes through the pixel
            r := Ray{origin, lowerLeftCorner.Add(horizontal.Mul(u)).Add(vertical.Mul(v))}
            color := rayColor(r)

            ir := int(255.99 * color.X)
            ig := int(255.99 * color.Y)
            ib := int(255.99 * color.Z)

            fmt.Fprintf(file, "%d %d %d\n", ir, ig, ib)
        }
    }
}

func main() {
    writePPM()
}

And we should finally get something like this:

Next Steps

The blog is already too long, and this is where we’ll stop, however if you are interested in completing the Ray Tracer, I encourage you to continue exploring the original "Ray Tracing in One Weekend" guide. It’s written in C++, but you can use any language, and you may be able to find community versions on Github.

As the name of original guide suggests, this is meant to be done in one Weekend, but take your time, and if you complete the guide, you can render even complex scenes like this.