Realtime Raytracing - Part 4 (Shadows, and Improving the Floor)

Written by Dean Edis.

Welcome to the Gimpy Software article on creating a GPU-powered Raytracer! (Part 4)

In this next article we'll be extending our implementation to include shadows and a more interesting floor plane.

Here’s the latest code (which can be pasted directly into the shader editor):

 

precision mediump float;

varying vec2 position; // Texture position being processed.
uniform float time;    // The current time.


// Define the position of the light.
vec3 lightPos()
{
  return vec3(-1.0, 1.0, -1.0);
}

// Calculate how much to illuminate a point, based on how much light it can see.
void applyLighting(vec3 rayPos, vec3 rayDir, inout vec4 rgb)
{
  vec3 v1 = normalize(rayDir);
  vec3 v2 = normalize(lightPos() - rayPos);
  
  // Get the angle between the reflected ray and the light.
  float angle = acos(dot(v1, v2));
  
  // Apply illumination. (Small angle gives high illumination.)
  float illum = 1.0 - angle / 3.141;
  rgb.rgb *= illum;
}

// Calculate whether a ray hits a sphere.
// If it hits, return the distance to the object.
float sphereHit(inout vec3 rayPos, inout vec3 rayDir, vec3 origin, float radius, inout vec4 rgb)
{
  vec3 p = rayPos - origin;

  // We need to solve a quadratic to find the distance.  
  float a = dot(rayDir, rayDir);
  float b = 2.0 * dot(p, rayDir);
  float c = dot(p, p) - (radius * radius);
  
  if (a == 0.0)
    return 999.0; // No hit.
  
  float f = b * b - 4.0 * a * c;
  if (f < 0.0)
    return 999.0;
  
  float lamda1 = (-b + sqrt(f)) / (2.0 * a);
  float lamda2 = (-b - sqrt(f)) / (2.0 * a);
  
  if (max(lamda1, lamda2) <= 0.0)
    return 999.0;
  
  // Find nearest hit point.
  if (lamda1 <= 0.0)
    lamda1 = lamda2;
  else if (lamda2 <= 0.0)
    lamda2 = lamda1;
  
  float dist = min(lamda1, lamda2);
  
  // Reflect ray off the surface.
  rayPos = rayPos + dist * rayDir;
  vec3 normal = normalize(rayPos - origin);
  rayDir = reflect(rayDir, normal);
  
  return dist;
}

// Calculate whether a ray hits the floor plane.
// If it hits, return the distance to the object.
float planeHit(inout vec3 rayPos, inout vec3 rayDir, float y, inout vec4 rgb)
{
  if (rayDir.y == 0.0)
    return 999.0; // Ray is parallel to the plane.
  
  float lamda = (y - rayPos.y) / rayDir.y;
  float dist = lamda * length(rayDir);
  
  // Reflect ray off the surface.
  rayPos = rayPos + dist * normalize(rayDir);
  rayDir = reflect(rayDir, vec3(0.0, 1.0, 0.0));
  
  // Use 'mod' to determine whether the floor tile is black or white.
  float a = mod(rayPos.x, 1.0) - 0.5;
  float b = mod(rayPos.z, 1.0) - 0.5;
  if (sign(a) == sign(b))
    rgb = vec4(0.0, 0.0, 0.0, 1.0);
  
  return dist;
}

// Check for a collision between ray and object.
// If there is one, return the distance to the object.
float objectHit(int id, inout vec3 rayPos, inout vec3 rayDir, inout vec4 rgb)
{
  if (id == 0)
  {
    // Object 0 - Red sphere.
    rgb = vec4(1.0, 0.0, 0.0, 1.0);
    return sphereHit(rayPos, rayDir, vec3(-0.3, -0.2, 0.0), 0.5, rgb);
  }
  
  if (id == 1)
  {
    // Object 1 - Yellow sphere.
    rgb = vec4(1.0, 1.0, 0.0, 1.0);
    return sphereHit(rayPos, rayDir, vec3(0.3, sin(time)* 0.6 + 0.3, -0.2), 0.3, rgb);
  }
  
  // Object 2 - The floor.
  rgb = vec4(1.0);
  return planeHit(rayPos, rayDir, -0.5, rgb);
}

// Cast a ray to simply see if it hits any object in the scene.
bool rayIsBlocked(inout vec3 inRayPos, inout vec3 inRayDir)
{
  for (int id = 0; id < 3; id++)
  {
    vec4 rgb = vec4(0.0);
    float d = objectHit(id, inRayPos, inRayDir, rgb);
    if (d > 0.0 && d < 999.0)
      return true;
  }
  
  return false;
}

// The bulk of the raytrace work is done here.
// Cast a ray through the scene and see if it hits an object.
vec4 castRay(inout vec3 inRayPos, inout vec3 inRayDir)
{
  vec4 rgb = vec4(0.0);    // Default color.
  float d_nearest = 999.0; // Distance to the nearest hit object.

  vec4 hit_rgb;
  vec3 bouncedRayPos, bouncedRayDir;
  vec3 testRayPos, testRayDir;
  
  // Check for a collision with each of the objects in the scene.
  for (int id = 0; id < 3; id++)
  {
    testRayPos = vec3(inRayPos); testRayDir = vec3(inRayDir);
    float d = objectHit(id, testRayPos, testRayDir, hit_rgb);
    
    // If there was a hit, and it occurred nearer than any other object...
    if (d > 0.0 && d < d_nearest)
    {
      // ...remember it.
      d_nearest = d;
      rgb = hit_rgb;
      bouncedRayPos = vec3(testRayPos); bouncedRayDir = vec3(testRayDir);
    }
  }

  // If we hit something...
  if (d_nearest > 0.0 && d_nearest < 999.0)
  {
    inRayPos = bouncedRayPos;
    inRayDir = bouncedRayDir;

    // Advance the ray by a small amount.
    vec3 pos = bouncedRayPos + bouncedRayDir / 1000.0;
    
    // Calculate the vector connecting the hit point to the light.
    vec3 dir = lightPos() - bouncedRayDir;
    if (rayIsBlocked(pos, dir))
    {
      // An object is blocking the light source - We're in shadow.
      rgb.rgb = vec3(0.0);
    }
    else
    {
      // The point can see the light - Calculate how much it can see.
      applyLighting(bouncedRayPos, bouncedRayDir, rgb);
    }
  }
  
  return rgb;
}

// The entry point to the shader.
void main() {
  // Invert the texture Y coordinate so our scene renders the right way up.
  vec2 p = vec2(position.x, 1.0 - position.y);
  
  // Set the camera properties.
  float cameraDist = 2.0;
  
  // Define the position and directions of the 'ray' to fire.
  vec3 rayPos = vec3(p.x - 0.5, 0.5 - p.y, -cameraDist);
  vec3 rayDir = normalize(vec3(p.x - 0.5, 0.5 - p.y, 1.0));
  
  // Fire the ray into the scene.
  vec4 rgb = castRay(rayPos, rayDir);
  if (rgb.a > 0.0)
  {
    // The ray hit an object!
    
    // Advance the ray by a small amount.
    rayPos += rayDir / 1000.0;
    
    // Cast the reflected ray, and combine the results with the original RGB.
    float shine = 0.2;
    vec4 rgb_bounce = castRay(rayPos, rayDir);
    rgb = rgb * (1.0 - shine) + rgb_bounce * shine;
  }
  else
  {
    // The ray missed all objects - Plot a black pixel.
    rgb = vec4(0.0, 0.0, 0.0, 1.0);
  }
  
  gl_FragColor = rgb;
}

…and here’s the effect on the output…

Raytracing: Shadows

 

As with the earlier articles, we’ve only made a couple of small changes, but the output has changed significantly.

The ‘tiles’ on the floor are added by toggling the pixel color between black and white, based on the x (left/right) and z (near/far) coordinate at which the ray hits the floor – See the latter section of planeHit. The size of the tiles can be changed by multiplying the x,z coordinates by any constant you choose. One of the benefits of immediate feedback in the shader editor is that it is easy to find a constant which works best for you.

The screenshot above also shows shadows – Clearest on the lower/right sections of the spheres. I’m continuously trying to keep the implementation I chose to be as simple as possible, as my emphasis is on keeping the rendering performance high. With this in mind castRay now makes a call to the new rayIsBlocked function. rayIsBlocked will re-use the objectHit code to determine whether there are any objects in between the ray’s ‘hit point’ and the light. No objects means the pixel gets the full benefit of the light, and an obstruction simply sets the RGB color to black. Many alternative methods of implementing shadows exist – Perhaps taking into account more lights or reflected light – But always as the cost of performance…

In the next, final, article we'll be adding camera movement to pan around the scene.