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?
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.
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.