Categories
Uncategorized

Ray tracing in a Swift playground part 2

Let’s continue working on our ray tracer and pick up where we left off last week. I want to thank CarolineJessyJeff and Mike for providing valuable feedback and performance improvement suggestions while working on this project.

First, as usual, we will do some code cleanup. In the first part we used the vec3.swift class because we wanted to understand the underlying data structures and operations between them, however, there is already a framework called simd which helps us do all the math we need. So rename vec3.swift to ray.swift since this class will only contain code related to the ray struct. Next, delete the vec3 struct as well as all the operations at the end. You should only retain the ray struct, as well as the color function.

Next, import the simd framework and then replace vec3 with float3 everywhere inside this file, and after that go to pixel.swift and repeat this last step in there as well. We are now officially depending on float3 only! While still in the pixel.swift we need to address one more concern: passing the array between the two functions makes the rendering quite slow. Here is how to time your code in the playground:

let width = 800
let height = 400
let t0 = CFAbsoluteTimeGetCurrent()
var pixelSet = makePixelSet(width, height)
var image = imageFromPixels(pixelSet)
let t1 = CFAbsoluteTimeGetCurrent()
t1-t0
image

Notice it takes 5 seconds (at least that is my case). This happens because in Swift arrays are defined as structs actually, and structs are always passed by value in Swift which means a copy of the array will me made when passing it, and copying a huge array is a performance bottleneck. There are two ways to fix this. One, the most elegant, is to wrap everything inside a class and make the array a class property. This way, the array would not need to be passed anymore between the local functions. The second way, is easier to implement and we will go with this one to save space in this article. All we need to do is combine the two functions into one like this:

public func imageFromPixels(width: Int, _ height: Int) -> CIImage {
    var pixel = Pixel(red: 0, green: 0, blue: 0)
    var pixels = [Pixel](count: width * height, repeatedValue: pixel)
    let lower_left_corner = float3(x: -2.0, y: 1.0, z: -1.0) // Y is reversed

    let horizontal = float3(x: 4.0, y: 0, z: 0)
    let vertical = float3(x: 0, y: -2.0, z: 0)
    let origin = float3()
    for i in 0..<width {
        for j in 0..<height {
            let u = Float(i) / Float(width)
            let v = Float(j) / Float(height)
            let r = ray(origin: origin, direction: lower_left_corner + u * horizontal + v * vertical)
            let col = color(r)
            pixel = Pixel(red: UInt8(col.x * 255), green: UInt8(col.y * 255), blue: UInt8(col.z * 255))
            pixels[i + j * width] = pixel
        }
    }
    let bitsPerComponent = 8
    let bitsPerPixel = 32
    let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
    let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.PremultipliedLast.rawValue)
    let providerRef = CGDataProviderCreateWithCFData(NSData(bytes: pixels, length: pixels.count * sizeof(Pixel)))
    let image = CGImageCreate(width, height, bitsPerComponent, bitsPerPixel, width * sizeof(Pixel), rgbColorSpace, bitmapInfo, providerRef, nil, true, CGColorRenderingIntent.RenderingIntentDefault)
    return CIImage(CGImage: image!)
}

Let’s time the execution again:

let width = 800
let height = 400
let t0 = CFAbsoluteTimeGetCurrent()
let image = imageFromPixels(width, height)
let t1 = CFAbsoluteTimeGetCurrent()
t1-t0
image

Much better! In my case the running time went from 5 seconds down to only 0.1 seconds. Ok, enough with the cleanup. Let’s do some graphics instead! We would like to draw more than just one sphere, perhaps way many more spheres. One neat trick to simulate the horizon if to draw a really huge sphere. Then we can put our smaller sphere on top of it to achieve a sitting-on-the-groundeffect.

For this, we need to abstract our current sphere code into a generic class. Let’s name it objects.swiftsince we will probably create other type of volumes in future beside spheres. Next, inside objects.swift we need to create a new struct which represents a hit event:

struct hit_record {
    var t: Float
    var p: float3
    var normal: float3
    init() {
        t = 0.0
        p = float3(x: 0.0, y: 0.0, z: 0.0)
        normal = float3(x: 0.0, y: 0.0, z: 0.0)
    }
}

Next, we need to create a protocol named hitable so various classes can conform to. This protocol only contains the hit function:

protocol hitable {
    func hit(r: ray, _ tmin: Float, _ tmax: Float, inout _ rec: hit_record) -> Bool
}

The next obvious step is to implement a sphere class:

class sphere: hitable  {
    var center = float3(x: 0.0, y: 0.0, z: 0.0)
    var radius = Float(0.0)
    init(c: float3, r: Float) {
        center = c
        radius = r
    }
    func hit(r: ray, _ tmin: Float, _ tmax: Float, inout _ rec: hit_record) -> Bool {
        let oc = r.origin - center
        let a = dot(r.direction, r.direction)
        let b = dot(oc, r.direction)
        let c = dot(oc, oc) - radius*radius
        let discriminant = b*b - a*c
        if discriminant > 0 {
            var t = (-b - sqrt(discriminant) ) / a
            if t < tmin {
                t = (-b + sqrt(discriminant) ) / a
            }
            if tmin < t && t < tmax {
                rec.t = t
                rec.p = r.point_at_parameter(rec.t)
                rec.normal = (rec.p - center) / float3(radius)
                return true
            }
        }
        return false
    }
}

As you might notice, the hit function is quite similar to the hit_sphere function we deleted from ray.swift, except we are now looking at hits that only occur during the interval tmax - tmin. Next, we need a way to add multiple objects to a list. An array of hitables seems to be the right choice:

class hitable_list: hitable  {
    var list = [hitable]()
    func add(h: hitable) {
        list.append(h)
    }
    func hit(r: ray, _ tmin: Float, _ tmax: Float, inout _ rec: hit_record) -> Bool {
        var hit_anything = false
        for item in list {
            if (item.hit(r, tmin, tmax, &rec)) {
                hit_anything = true
            }
        }
        return hit_anything
    }
}

Back to ray.swift, we need to modify the color function to factor a hit-record variable into the color calculation:

func color(r: ray, world: hitable) -> float3 {
    var rec = hit_record()
    if world.hit(r, 0.0, Float.infinity, &rec) {
        return 0.5 * float3(rec.normal.x + 1, rec.normal.y + 1, rec.normal.z + 1);
    } else {
        let unit_direction = normalize(r.direction)
        let t = 0.5 * (unit_direction.y + 1)
        return (1.0 - t) * float3(x: 1, y: 1, z: 1) + t * float3(x: 0.5, y: 0.7, z: 1.0)
    }
}

Finally, back to pixel.swift we need to change the imageFromPixels function to allow the including of more objects:

public func imageFromPixels(width: Int, _ height: Int) -> CIImage {
    ...
    let world = hitable_list()
    var object = sphere(c: float3(x: 0, y: -100.5, z: -1), r: 100)
    world.add(object)
    object = sphere(c: float3(x: 0, y: 0, z: -1), r: 0.5)
    world.add(object)
    for i in 0..<width {
        for j in 0..<height {
            let u = Float(i) / Float(width)
            let v = Float(j) / Float(height)
            let r = ray(origin: origin, direction: lower_left_corner + u * horizontal + v * vertical)
            let col = color(r, world: world)
            pixel = Pixel(red: UInt8(col.x * 255), green: UInt8(col.y * 255), blue: UInt8(col.z * 255))
            pixels[i + j * width] = pixel
        }
    }
    ...
}

In the main playground page, see the generated new image:

alt text

Nice! If you look closely you will notice the edges exhibit the aliasing effect, and this happens because we do not have any blending of colors for the pixels on the edge. To overcome this, we need to sample the color multiple times by randomly generating values that are within the range we want, so we can blend them together and achieve an anti-aliasing effect.

But first, let’s also create a camera class inside ray.swift as it will turn handy later. Practically, we just move the improvised camera we had inside the imageFromPixels function and put it in its right place:

struct camera {
    let lower_left_corner: float3
    let horizontal: float3
    let vertical: float3
    let origin: float3
    init() {
        lower_left_corner = float3(x: -2.0, y: 1.0, z: -1.0)
        horizontal = float3(x: 4.0, y: 0, z: 0)
        vertical = float3(x: 0, y: -2.0, z: 0)
        origin = float3()
    }
    func get_ray(u: Float, _ v: Float) -> ray {
        return ray(origin: origin, direction: lower_left_corner + u * horizontal + v * vertical - origin);
    }
}

The imageFromPixels function now looks like this:

public func imageFromPixels(width: Int, _ height: Int) -> CIImage {
    ...
    let cam = camera()
    for i in 0..<width {
        for j in 0..<height {
            let ns = 100
            var col = float3()
            for _ in 0..<ns {
                let u = (Float(i) + Float(drand48())) / Float(width)
                let v = (Float(j) + Float(drand48())) / Float(height)
                let r = cam.get_ray(u, v)
                col += color(r, world)
            }
            col /= float3(Float(ns));
            pixel = Pixel(red: UInt8(col.x * 255), green: UInt8(col.y * 255), blue: UInt8(col.z * 255))
            pixels[i + j * width] = pixel
        }
    }
    ...
}

Notice that we use a variable named ns and assign a value of 100 to it so we can sample the color multiple times using randomly generated values as we discussed above. In the main playground page, see the generated new image:

alt text

Much better looking! However, we notice our rendering took 7 seconds which can be reduced by using a smaller sample value, such as 10. Alright, now that we have multiple rays per pixel, we can finally think of creating matte (diffuse) materials. This kind of materials do not emit any light and usually absorb all the light that is directed towards them and blend it with their own color. The light that reflects of a diffuse material has its direction randomized. We can compute this with the following function inside objects.swift:

func random_in_unit_sphere() -> float3 {
    var p = float3()
    repeat {
        p = 2.0 * float3(x: Float(drand48()), y: Float(drand48()), z: Float(drand48())) - float3(x: 1, y: 1, z: 1)
    } while dot(p, p) >= 1.0
    return p
}

Then, back to ray.swift we need to modify the color function to factor the new random function into the color calculation:

func color(r: ray, _ world: hitable) -> float3 {
    var rec = hit_record()
    if world.hit(r, 0.0, Float.infinity, &rec) {
        let target = rec.p + rec.normal + random_in_unit_sphere()
        return 0.5 * color(ray(origin: rec.p, direction: target - rec.p), world)
    } else {
        let unit_direction = normalize(r.direction)
        let t = 0.5 * (unit_direction.y + 1)
        return (1.0 - t) * float3(x: 1, y: 1, z: 1) + t * float3(x: 0.5, y: 0.7, z: 1.0)
    }
}

In the main playground page, see the generated new image:

alt text

If you forgot to decrease ns from 100 to 10 your rendering took somewhat around 18 seconds! However, if you decreased the value, the rendering time is down to only about 1.9 seconds which is not too shabby for a basic matte surface ray tracer.

This image looks great, however, we can also get rid of those small ripples easily. Notice that inside the color function we set Tmin to be 0.0 and this seems to disturb some of the cases where the color needs to be computed correctly. If we set Tmin to be very small but still positive, something like 0.01, you will notice that the difference is highly noticeable!

alt text

Now, this image looks gorgeous! Stay tuned for the next part of this series, where we will look into topics such as specular lights, transparency, refraction and reflection. The source code is posted on Github as usual.

Until next time!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s