As part of a school assignment, we had to create a non-real-time ray tracer both to learn how graphics are handled and how to use trigonometry and algebra. The idea was to recreate a reference image given by the teacher the final image should look like the image on the top of the page. Soft shadows, ambient occlusion, AA, and multi-threading where optional I did them anyway since I had time. Another Image we had to recreate was the first raytraced image created by Turner Whitted. I used SFML to handle the drawing of pixels for me so I can focus on creating the ray tracer itself.
This project was a solo project as each student had to create their own ray tracer, so all the functionality is solely made by me with of course some help and communication between other students.
The very basics of a ray tracer is to draw shapes based on each ray shot to a pixel on the screen. If the ray hit a shape in the world the corresponding pixel will be draw. To draw the shapes, you need a collision detection between a ray and the given shape, there are multiple calculation for each different shape with the most basic one being a circle and or sphere. Triangle intersection is a bit trickier but certainly doable having the ability to draw triangles opens the whole world for you as you can practically create any shape you want then.
bool Sphere::Intersect(const Ray& ray, vec3& hit, vec3& normal) const
{
constexpr float radius = 1.f;
const vec3& rayOrg = ray.GetOrigin();
const vec3& rayDir = ray.GetDir();
const float projection = -rayOrg.dot(rayDir);
const vec3 closestHit = rayDir * projection;
const vec3 localHit = closestHit + rayOrg;
if(projection < math::FLOAT_ERROR)
return false;
const float opp = localHit.dot(localHit);
if(opp > radius)
return false;
const float t = sqrtf(radius - opp);
float tNear = projection - t;
const float tFar = projection + t;
if(projection < radius)
tNear = tFar;
hit = rayOrg + rayDir * tNear;
normal = hit;
return true;
}
As seen above this is the function I use to check if a ray has hit a sphere, using vector math you can easily calculate whether a ray has hit the sphere and where exactly on the surface it has hit. There are many more function to calculate a plane (which is the simplest one), triangles, cubes, cylinders, pyramids etc.
To give more life to the shapes in the world and a more accurate representation of the actual shapes (seeing their actual shape), shading is an important step for a ray racer and any renderer for the matter. I used simple Lambertian shading to calculate how light falls on top of the shape and how shade will be represented on top of the shapes. To make things shiny and show of the actual light on top of a shape I used Blinn-Phong to calculate the specular level on top of the shapes.
//Get a color based upon Lambertian shading.
Color Renderer::GetLambertian(const IntersectionInfo& hitInfo, const LightInfo& lightInfo) const
{
//Calculate the pixel brightness based on direction the light is perceived.
const float d = hitInfo.hitNormal.dot(lightInfo.lightDir * -1.f);
const float brightness = std::max(0.0f, d);
return lightInfo.lightColor * brightness;
}
//Get a color based upon the specular level (blinn-phong).
Color Renderer::GetSpecularColor(const IntersectionInfo& hitInfo, const LightInfo& lightInfo, const Material& material) const
{
const vec3 halfwayDir = (lightInfo.lightDir + (hitInfo.rayShot.GetDir())).normalize();
const float spec = material.specularDampener * std::pow(std::max((halfwayDir * -1.f).dot(hitInfo.hitNormal), 0.f), material.specularLevel);
return lightInfo.lightColor * spec;
}
Certain shapes reflect light, allowing you to see other shapes on their surface, a mirror for example reflect almost all light allowing you to see yourself clearly. To calculate reflection, you need to shoot another ray from the reflected angle on top of the surface of the shape. Based on what the second bounce gives you, you add the color of the other hit shape in the reflection on top of the original shape. The more bounces you reflect to more accurate your scene and shape will be but at the cost of performance as more rays need to be shot per pixel if they are reflected.
//Get a color based upon reflections
Color Renderer::GetReflectionColor(const IntersectionInfo& hitInfo, const Material& material, unsigned int rayDepth) const
{
const bool outside = hitInfo.rayShot.GetDir().dot(hitInfo.hitNormal) < 0.f;
const vec3 bias = hitInfo.hitNormal * math::FLOAT_ERROR;
const vec3 reflect = hitInfo.rayShot.GetDir().Reflect(hitInfo.hitNormal).normalize();
const vec3 reflectOrg = outside ? hitInfo.hitPoint + bias : hitInfo.hitPoint - bias;
const Ray reflectRay(reflectOrg, reflect);
return CastRay(reflectRay, rayDepth + 1) * material.reflectivity;
}
Another step in creating a realistic scene for your ray tracer is refraction, in short when a ray goes inside of a "non-opaque" surface and bounces around. A prime example of this is glass, where light enters the glass but gets refracted (bend) to a new angle. Each surface can have a different angle usually denoted as Index of Refraction, the actual science behind it is more complex than simply an angle but for a simple ray tracer this works very well. Refraction is usually paired with Fresnel to calculate the angle itself if the index of the surface and the direction of the ray is given to a Fresnel formula it will return either if the ray has refracted inside the surface (if this is the case it simply reflected) or it exactly went inside the surface which means it has refracted so the angle should be calculated for that.
vec3 vec3::Refract(const vec3& normal, float etaI, float etaR) const
{
float cosAngle = normal.dot(*this);
//Check if we are currently inside our geometric object.
vec3 norm = normal;
//Outside the surface
if(cosAngle < 0.f)
{
cosAngle = -cosAngle;
}
//Inside the surface flip normal and we are now working inside a different refraction index.
else
{
if(etaR > etaI)
std::swap(etaI, etaR);
norm = normal * -1.f;
}
//Snell's law, where eta1 is air and eta2 is the index of refraction which determines the refraction of the other surface.
const float eta = etaI / etaR;
const float totInternalReflect = 1.f - (eta * eta) * (1.f - (cosAngle * cosAngle));
//We are only reflecting "light" not refracting it.
if(totInternalReflect < 0.f)
return math::vector3::ZERO;
return (*this * eta) + norm * (eta * cosAngle - sqrtf(totInternalReflect));
}
As an extra for the assignment, I added soft shadows to my ray tracer. The simple solution is to get more samples per pixel where from the light; random rays within a sphere would be shot towards the shape, smoothing out the hard edges of the shadow.
if (settings::SOFT_SHADOWS)
{
vec3 shadowDir;
Color shadowCol;
for (int i = 0; i < settings::NUM_SHADOW_SAMPLES; ++i)
{
IntersectionInfo shadeInfo;
LightInfo lightInfo;
light->CalculateLightInfo(hitInfo.hitPoint, lightInfo, shadeInfo.tNear);
shadowDir = math::GetRandomPointInSphere(lightInfo.lightDir, light->GetRadius()) * -1.f;
shadowCol += CalculateDiffuseColor(hitInfo, lightInfo, shadeInfo, shadowDir, ambient, objectMat);
}
surfaceCol += (shadowCol / settings::NUM_SHADOW_SAMPLES);
}
Another extra assignment was Ambient Occlusion. Ambient Occlusion is how bright the scene light should be shining of the shapes surface. Usually, you can notice this on bottom of certain shapes as light does not shine as bright as for example on the top of the shape itself. Ambient Occlusion works by shooting out multiple rays from the intersection point towards a random direction inside a small sphere.
//Ambient calculations
Color Renderer::CalculateAmbientOcclusion(const Color& ambientLight, const IntersectionInfo& hitInfo) const
{
int numRaysHit = 0;
constexpr float radius = 0.2f;
for (int i = 0; i < settings::NUM_AMBIENT_OCCLUSION_SAMPLES; ++i)
{
vec3 rayDir = math::GetRandomDirectionInSphere(radius);
const float d = hitInfo.hitNormal.dot(rayDir);
if (d < 0.f)
{
rayDir = rayDir * -1.f;
}
IntersectionInfo ambientInfo;
Ray r(hitInfo.hitPoint + (hitInfo.hitNormal * 0.01f), rayDir);
if (TraceRay(r, ambientInfo) && ambientInfo.tNear < radius * math::randomEngine.GetFloatInRange(0.f, 1.f))
{
numRaysHit++;
}
}
const float ambientRatio = 1.f - (static_cast(numRaysHit) / settings::NUM_AMBIENT_OCCLUSION_SAMPLES);
return ambientLight * ambientRatio;
}
As seen in some of the images above some of the edges on the shapes are very jagged to smooth these out Anti-Aliasing (AA) can be applied. The simplest form of AA that can be applied is Super-Sampled Anti-Aliasing (SSAA), I sample multiple pixels around the main pixel and get the average color for that pixel.
if (settings::ENABLE_SSAA)
{
for (int sample = 0; sample < settings::NUM_AA_SAMPLES; ++sample)
{
const float sampleX = math::randomEngine.GetFloatInRange(0.f, 1.f);
const float sampleY = math::randomEngine.GetFloatInRange(0.f, 1.f);
const vec3 rayDirection = m_scene->camera.GetRayDirection(static_cast<float>(x) + sampleX, static_cast<float>(y) + sampleY);
//Trace a ray from the pixel coordinates down into the world space.
const Ray primeRay(m_scene->camera.GetPosition(), rayDirection);
pixelColor += CastRay(primeRay, 0);
}
pixelColor /= settings::NUM_AA_SAMPLES;
}
To improve performance, I added simple multi-threading to the ray tracer, where each thread would get an assigned block or row of set pixels and tries to draw this.
const unsigned int numRenderWorkers = (showProgress ? numThreads - 1 : numThreads);
for (unsigned int i = 0; i < numRenderWorkers; ++i)
{
switch (style)
{
case Render_Style::ROWS:
m_threads[i] = std::thread(&ThreadRenderer::RowRender, this, renderOperation, i, numRenderWorkers);
break;
case Render_Style::CHUNKS:
m_threads[i] = std::thread(&ThreadRenderer::ChunkRender, this, renderOperation, GetFreeRenderChunk());
break;
default:;
}
}
void ThreadRenderer::ChunkRender(const RenderCallback& renderOperation, const RenderChunk* chunk)
{
for (size_t i = chunk->startY; i < chunk->startY + chunk->sizeY; i++)
{
for (size_t j = chunk->startX; j < chunk->startX + chunk->sizeX; ++j)
{
renderOperation(static_cast<int>(j), static_cast<int>(i));
m_progressBar.AddProgress();
}
}
chunk = GetFreeRenderChunk();
if (chunk != nullptr)
{
ChunkRender(renderOperation, chunk);
}
}
void ThreadRenderer::RowRender(const RenderCallback& renderOperation, unsigned int threadId, unsigned int numThreads)
{
for (size_t i = threadId; i < m_screenHeight; i += numThreads)
{
for (size_t j = 0; j < m_screenWidth; ++j)
{
renderOperation(static_cast<int>(j), static_cast<int>(i));
m_progressBar.AddProgress();
}
}
}