Realtime Raytracing - Part 3 (Lighting)

Written by Dean Edis.

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

In this next article we'll be extending our implementation to cover lighting.

As before, let’s start with the code. It gives us something to reference, and can be pasted directly into the shader editor for immediate results:

 

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));
  
  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);
}

// 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;

    // Calculate how much light 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;
}

The castRay function now includes a call to the new applyLighting code. The light we’re simulating is a point light – A light which emits a constant amount of light in all directions. When a ray hits an object we now calculate the angle between the reflected (‘bounced’) vector and the position of the light. When the angle is small it means a greater portion of the light is reflected; larger angles decrease the RGB brightness.

We use the lightPos function to define the position of the light. You can easily change it to move the light over time without adding significant overhead to your GPU.

Realtime Raytracing: Lighting

 

To add a new feature consider using the distance of the reflected ray to the light to influence the color of the pixel. Linear, or a higher power, of ‘fall off’ will more accurately emulate lights in the real world. Or add a second light source, or colored light. Be careful though – At some point the extra complexity might stress your GPU a bit too much to maintain your frame rate.

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