Raytracing with Shadertoy

Online raytracing tutorial using Shadertoy. - yduf

see also:

This tutorial is heavily based upon ssloy/tinyraytracer and lesser on Ray Tracing in One Weekend from Peter Shirley It’s goal is to have a working raytracer in a minimum of steps.

But rather than doing it offline with c++, I focus on doing it in a webbrowser, using Shadertoy. It has the benefit of making it very interactive, simpler (core type and function like vec3 and reflect are already there) and faster (realtime) because of the use of shader and GPU. It also has some drawbacks:

So in the end perhaps more compliated for complete beginer because of some shadertoy Magic. I let you judge (by refering yourself to the original tutorial).

Most of the text is not mine and has been copied from the other tutorials, it’s there to help understood the path taken and code modification.

Using shader allows to use direclty vec3 object that are part of the shading langage. The drawback is that there is no class and only struct and functions can be used.

This tutorial is primary made for myself and follow the same plan than the one I am referring to. When relevant I kept the same title as the chapter it maches on other tutorials.

1 - Generate an image

When creating a new shader on Shadertoy, we get some sample code which is close to the following one. Which is made to get the exact same rendering than in other tutorial.

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    // Normalized pixel coordinates (from 0 to 1)
    vec2 uv = fragCoord/iResolution.xy;

    // Gradiant x,y varying pixel color
    vec3 col = vec3( uv.y , uv.x, 0);

    // Output to screen
    fragColor = vec4(col,1.0);
}

mainImage is the equivalent of the render function. It’s job is to output for a given screen coordinate an color for this pixel. It uses normalzed coordinates (between 0 and 1). And produce the following output:

Notice: that the uv coordinates we get has the origine (0,0) at the bottom left, since the color is dark there (which is not necessary the same on the other tutorials).

2 - Rays, a simple camera, and background

At the core of a ray tracer is to send rays through pixels and compute what color is seen in the direction of those rays. This is of the form ​ calculate which ray goes from the eye to a pixel, compute what that ray intersects, and compute a color for that intersection point. ​ When first developing a ray tracer, I always do a simple camera for getting the code up and running. I also make a simple ​ color(ray) ​ function that returns the color of the background (a simple gradient).

Peter Shirley wrote “Note that I do not make the ray direction a unit length vector because I think not doing that makes for simpler and slightly faster code.”

At least the intersection code that I am using latter assume unit vector for ray direction, I spend sometime understanding this, and to avoid further issue I choose to normalize ray vector upfront in the camera code.

vec3 cast_ray( in vec3 orig, in vec3 dir) {
    // draw sky and land
    if( dir.y > 0.) {        // sky
        return vec3(0., 0., 0.5);
    }
    else {                  // land
        return vec3(0., 0.5, 0.);
    }
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    // image coordinate origin (0,0) in middle of image
    // from [-1, 1] on y with ratio preserved, on x
    vec2 uv = (2.*fragCoord - iResolution.xy ) / iResolution.y;

    // camera coordinate
    float focal = 1.0;              // avoid computing tan
    vec3 origin = vec3( 0, 0, 0);
    // sphere intersection code expect normalized direction: so we do it upfront
    vec3 dir    = normalize( vec3( uv, -focal));

    // world coordinate == camera coordinate    
    vec3 col = cast_ray( origin, dir);

    // Output to screen
    fragColor = vec4(col,1.0);
}

3 - ray tracing

Now for each pixel we will form a ray coming from the origin and passing through our pixel, and then check if this ray intersects with the sphere:

caption

I want to define one sphere in my code and draw it without being obsessed with materials or lighting.

We already have the camera in place, so we just have to check if ray originating from the camera, intersect with the sphere. As camera coordinate and world coordinate are the same we just have to compute ray/sphere intersection (code taken from this blog). We just had that check to the cast_ray function:

struct Sphere
{
    // sphere properties
    vec3 center;
    float radius;
};

const Sphere S = Sphere( vec3( 3., 0., -10), 2.);

// https://www.shadertoy.com/view/ldScDc
// sphere intersection
float sphere( in vec3 orig, in vec3 udir, 	// !udir = normlized
              in vec3 center, in float radius) {
    vec3 rc = orig - center;
    float c = dot(rc, rc) - (radius * radius);
    float b = dot(udir, rc);
    float d = b * b - c;
    float t = -b - sqrt(abs(d));
    float st = step(0.0, min(t, d));
    return mix(-1.0, t, st);
}

vec3 cast_ray( in vec3 orig, in vec3 dir) {
    
    // sphere intersection
    if( sphere( orig, dir, S.center, S.radius) > 0.) {
        return vec3(0.2, 0.7, 0.8); // sphere color
    }
    
    // draw sky and land
    if( dir.y > 0.) {        // sky
        return vec3(0., 0., 0.5);
    }
    else {                  // land
        return vec3(0., 0.5, 0.);
    }
}

4 - Lighting

The image is perfect in all aspects, except for the lack of light. Throughout the rest of the article we will talk about lighting. We will trick the eye by drawing completely non-physical, but visually plausible results. To start with: why is it cold in winter and hot in summer? Because the heating of the Earth’s surface depends on the angle of incidence of the Sun’s rays. The higher the sun rises above the horizon, the brighter the surface is. Conversely, the lower it is above the horizon, the dimmer it is. And after the sun sets over the horizon, photons don’t even reach us at all.

Back our spheres: we emit a ray from the camera (no relation to photons!) at it stops at a sphere. How do we know the intensity of the intersection point illumination? In fact, it suffices to check the angle between a normal vector in this point and the vector describing a direction of light. The smaller the angle, the better the surface is illuminated. Recall that the scalar product between two vectors a and b is equal to product of norms of vectors times the cosine of the angle between the vectors: a*b = a   b cos(alpha(a,b)). If we take vectors of unit length, the dot product will give us the intensity of surface illumination.

Thus, in the cast_ray function, instead of a constant color we will return the color taking into account the light sources:

struct Light {
    vec3  position;
    float intensity;
};

const Light  L = Light ( vec3( 10., 30., 10.), 2.);

vec3 cast_ray( in vec3 orig, in vec3 dir) {
    // sphere intersection
    float dist_i = sphere( orig, dir, S.center, S.radius); 
    if( dist_i > 0.) {
		const vec3 diffuse_color = vec3(0.2, 0.7, 0.8); // sphere color
        
        // compute normal to surface hit
        vec3 hit = orig + dir*dist_i;
        vec3 N = normalize(hit - S.center);
        
        // basic diffuse lightning
        float diffuse_light_intensity = 0.;
        vec3  light_dir = normalize(L.position - hit);
        diffuse_light_intensity  += L.intensity * max(0., dot( light_dir, N));
    
    	return diffuse_color * diffuse_light_intensity;
    }
    
    // draw sky and land
    ...
}

5 - Better materials

The dot product trick gives a good approximation of the illumination of matt surfaces, in the literature it is called diffuse illumination. What should we do if we want to draw shiny surfaces?

This trickery with illumination of matt and shiny surfaces is known as Phong reflection model. The wiki has a fairly detailed description of this lighting model. It can be nice to read it side-by-side with the source code. Here is the key picture to understanding the magic:

caption

So lets introduce Phong Material to the code:

struct Material {
    vec2 albedo;
    vec3 diffuse_color;
    float specular_exponent;
};

const Material ivory = Material(vec2(0.6,  0.3), 
                                vec3(0.2, 0.7, 0.8), 	// sphere color 
                                50.);

vec3 cast_ray( in vec3 orig, in vec3 dir) {
    
    // sphere intersection
    float dist_i = sphere( orig, dir, S.center, S.radius); 
    if( dist_i > 0.) {
        const Material material = ivory;	// sphere material
        
        // compute normal to surface hit
        vec3 hit = orig + dir*dist_i;
        vec3 N = normalize(hit - S.center);
        
        // basic diffuse lightning
        float diffuse_light_intensity  = 0.;
        float specular_light_intensity = 0.;
        
        // for every light (only one here)
        vec3  light_dir = normalize(L.position - hit);
        diffuse_light_intensity  += L.intensity * max(0., dot( light_dir, N));
        specular_light_intensity += pow( max(0., dot( -reflect(-light_dir, N), dir)), 
                                        	material.specular_exponent)*L.intensity;
        
    	return material.diffuse_color*diffuse_light_intensity* material.albedo[0] 
                  + vec3(1., 1., 1.)*specular_light_intensity* material.albedo[1];
    }
    
    // draw sky and land
    ...
}

Beyond the spheres

To continue the tutorial by adding shadows and reflections, we need more than one objects. Rather than using more spheres, I wanted to add a proper floor now.

To be continued…

Written on January 22, 2019, Last update on June 15, 2022
yduf raytracing tutorial shader