Multithreaded Software Ray Tracer

Description

This project was one of two school assignments (see Software/Hardware Rasterizer) during my first graphics programming course. Its goal was to introduce creating a 3D world from scratch, having zero beforehand knowledge about anything graphics programming related. It wasn’t easy, but it was very rewarding to see the slightest progress as I discover more about it.

I continued the project in my free time to polish and refine it a bit more. I made it into a little framework, to make it easier to create new scenes with different objects to render. Numerous parts of code were revisited over time and profiled/optimized where necessary.

Topics Covered

Though with all the progress, this Ray Tracer can definitely be expanded upon in terms of optimization or extra features such as Accelerated Structures, Indirect Lighting, Reflections, Soft Shadows, and Anti-Aliasing to name a few.

Most Interesting Code Snippets

Multithreaded Pixel Loop
//Scene variables
auto pCamera = pScene->GetCamera();
const auto& pPrimitives = pScene->GetPrimitiveManager().GetPrimitives();
const auto& pLights = pScene->GetLightManager().GetLights();
const auto& pMaterials = pScene->GetMaterialManager().GetMaterials();
const auto& keyBindInfo = pScene->GetKeyBindInfo();

//Multithread with fixed indexing -> divide screen into segments (so that the screen is evenly divided over all cores)
unsigned int segment = (unsigned int)std::floorf(max / float(cores));

//Giving each thread a portion of where to start rendering from and where to stop (= what segment it needs to render)
unsigned int startIdxCurrentThread = i * segment;
unsigned int maxIdxCurrentThread = startIdxCurrentThread + segment;

//Start pixel loop
Ray cameraRay{};
for (unsigned int index = startIdxCurrentThread; index < maxIdxCurrentThread && index < max; ++index)
{
	//Create camera ray
	unsigned int x = index % m_Width;
	unsigned int y = index / m_Width;
	pCamera->CalculateCameraRay(x, y, cameraRay);

	//T is set to max, needs a check later who has the closest T value (closest to screen)
	HitRecord hitRecord{};
	RGBColor finalColor{ 0,0,0 };

	//Loop over all primitives to render
	for (size_t i = 0; i < pPrimitives.size(); ++i)
	{
		if (pPrimitives[i]->Hit(cameraRay, hitRecord))
		{
			//Check for who is more in front than the other primitive
			if (hitRecord.tValue < hitRecord.tClosest)
			{
				//Update closest hit
				hitRecord.tClosest = hitRecord.tValue;
				
				//Shade or re-shade for closest primitive with current material
				finalColor = ShadeCurrentPixel(pPrimitives, pLights, pMaterials[i], cameraRay, hitRecord, keyBindInfo);
			}
		}
	}

	//Clamp in case of overflow
	if (finalColor.r > 1.f || finalColor.g > 1.f || finalColor.b > 1.f)
		finalColor.MaxToOne();

	//Render current pixel with correct color
	m_pBackBufferPixels[x + (y * m_Width)] = SDL_MapRGB(m_pBackBuffer->format,
		static_cast<uint8_t>(finalColor.r * 255),
		static_cast<uint8_t>(finalColor.g * 255),
		static_cast<uint8_t>(finalColor.b * 255));
}
Pixel Shading
Elite::RGBColor Elite::Renderer::ShadeCurrentPixel(const std::vector<Primitive*>& pPrimitives, const std::vector<Light*>& pLights, Material* pMaterial, const Ray& cameraRay, HitRecord& hitRecord, const KeyBindInfo& keyBindInfo)
{
	//Reset new variables for calculating final color
	Elite::RGBColor irradiance{ 0,0,0 };
	Elite::RGBColor BRDF{ 0,0,0 };
	Elite::RGBColor finalColor{ 0,0,0 };

	Ray shadowRay{};
	for (Light* pLight : pLights)
	{
		//Check if we enabled this light or not
		if (!pLight->GetIsActive()) continue;

		//Option to enable/disable hard shadows
		if (keyBindInfo.UseHardShadows)
		{
			//Uses previous hit record information to calculate and store ray information in the shadowRay object 
			pLight->CalculateShadowRay(hitRecord, shadowRay);

			//Hard shadow check
			bool hitsPrimitive{};
			for (Primitive* pPrimitive : pPrimitives)
			{
				if (pPrimitive->Hit(shadowRay, hitRecord, true))
				{
					hitsPrimitive = true;
					break;
				}
			}

			//If primitive is hit, it means something is blocking the path of the ray 
			// -> we skip light calculation because there's no light contribution at this point
			if (hitsPrimitive) continue;
		}

		//Otherwise continue calculations (depending on the ImageRenderInfo option)
		if (keyBindInfo.ImageRenderInfo == ImageRenderInfo::OnlyIrradiance)
		{
			irradiance = pLight->GetCalculatedIrradianceColor(hitRecord);
			finalColor += irradiance;
		}
		else if (keyBindInfo.ImageRenderInfo == ImageRenderInfo::OnlyBRDF)
		{
			BRDF = pMaterial->Shade(hitRecord, pLight->GetDirection(hitRecord), -cameraRay.Direction);
			finalColor += BRDF;
		}
		else if (keyBindInfo.ImageRenderInfo == ImageRenderInfo::All)
		{
			irradiance = pLight->GetCalculatedIrradianceColor(hitRecord);
			BRDF = pMaterial->Shade(hitRecord, pLight->GetDirection(hitRecord), -cameraRay.Direction);
			finalColor += irradiance * BRDF;
		}
	}

	return finalColor;
}
BRDF's (Lambert, Phong, Cook-Torrance)
Elite::RGBColor BRDF::Lambert(const Elite::RGBColor& diffColor, float diffReflectance)
{
	return diffColor * diffReflectance / (float)E_PI;
}

Elite::RGBColor BRDF::Lambert(const Elite::RGBColor& albedoColor, const Elite::RGBColor& kd)
{
	return albedoColor * kd / (float)E_PI;
}

Elite::RGBColor BRDF::Phong(float specReflectance, float phongExp, const Elite::FVector3& lightDir, const Elite::FVector3& invViewDir, const Elite::FVector3& hitNormal)
{
	Elite::FVector3 reflect = lightDir - 2 * Elite::Dot(hitNormal, lightDir) * hitNormal;
	float a = Elite::Dot(reflect, invViewDir);

	float phongSpecReflect = specReflectance * (powf(a, phongExp));
	Elite::RGBColor phongSpecReflect_color{ phongSpecReflect, phongSpecReflect, phongSpecReflect };

	return phongSpecReflect_color;
}

Elite::RGBColor BRDF::LambertCookTorrance(const Elite::RGBColor& albedoColor, int metalness, float roughness, const Elite::FVector3& lightDir, const Elite::FVector3& invViewDir, const Elite::FVector3& hitNormal)
{
	//Roughness Squared
	float roughnessSquared = (roughness * roughness);

	//Determine F0 value (0.04, 0.04, 0.04) or Albedo based on Metalness
	Elite::RGBColor F0 = (metalness == 0) ? Elite::RGBColor(0.04f, 0.04f, 0.04f) : albedoColor;

	//Calculate halfVector between view and light
	FVector3 halfVector = GetNormalized(invViewDir + lightDir);

	//Re-usable dot products for functions (clamp needed to prevent overflow of color issue!)
	float dotNL = Clamp(Elite::Dot(hitNormal, lightDir), 0.f, 1.f);
	float dotNV = Clamp(Elite::Dot(hitNormal, invViewDir), 0.f, 1.f);
	float dotNH = Clamp(Elite::Dot(hitNormal, halfVector), 0.f, 1.f);
	float dotVH = Clamp(Elite::Dot(invViewDir, halfVector), 0.f, 1.f);
	
	//Calculate Fresnel (F)
	Elite::RGBColor fresnel = BRDF::Fresnel(dotVH, F0);
	
	//Calculate Normal Distribution
	float normalDistribution = BRDF::NormalDistribution(dotNH, roughnessSquared);

	//Calculate geometry (Smith's function)
	float kDirect = powf(roughnessSquared + 1, 2) / 8.f;
	float geometry = BRDF::GeometryMasking(dotNV, kDirect) * BRDF::GeometryShadowing(dotNL, kDirect);

	//Calculate specular
	RGBColor specular = CalculateSpecular(normalDistribution, fresnel, geometry, dotNL, dotNV);

	//Determine KD
	Elite::RGBColor kd = (metalness == 0) ? (Elite::RGBColor(1, 1, 1) - fresnel) : Elite::RGBColor(0, 0, 0);

	//Calculate diffuse
	Elite::RGBColor diffuse = BRDF::Lambert(albedoColor, kd);

	//Return final color (keep in mind the kd is implemented in the diffuse and the ks was implemented into the fresnel)
	return (diffuse + specular);
}

Elite::RGBColor BRDF::Fresnel(float dotVH, const Elite::RGBColor& f0)
{
	Elite::RGBColor fresnel = f0 + (Elite::RGBColor(1,1,1) - f0) * powf(1 - dotVH, 5);
	return fresnel;
}

float BRDF::NormalDistribution(float dotNH, float roughnessSquared)
{
	float a = roughnessSquared * roughnessSquared;
	float denominator = powf( (powf(dotNH, 2)) * (a - 1) + 1, 2);

	float normalDistribution = a / ((float)E_PI * denominator);
	return normalDistribution;
}

float BRDF::GeometryMasking(float dotNV, float k)
{
	float geometryMasking = dotNV / (dotNV * (1.f - k) + k);
	return geometryMasking;
}

float BRDF::GeometryShadowing(float dotNL, float k)
{
	float geometryShadowing = dotNL / (dotNL * (1.f - k) + k);
	return geometryShadowing;
}

Elite::RGBColor BRDF::CalculateSpecular(float D, const Elite::RGBColor& F, float G, float dotNL, float dotNV)
{
	Elite::RGBColor dfg{ F * D * G };
	return Elite::RGBColor{ dfg / 4.f * dotNV * dotNL };
}
Triangle Hit Check
bool Triangle::Hit(const Ray& ray, HitRecord& hitRecord, bool isShadowCheck) const
{
    //Front or backface check, if none is selected then it will simply continue
    float dot = Dot(m_Normal, ray.Direction);

    if (!isShadowCheck)
    {
        if (m_CullMode == CullMode::Backface && dot > 0)
            return false;
        if (m_CullMode == CullMode::Frontface && dot < 0)
            return false;
    }
    else
    {
        if (m_CullMode == CullMode::Backface && dot < 0)
            return false;
        if (m_CullMode == CullMode::Frontface && dot > 0)
            return false;
    }

    if (dot >= 0 - FLT_EPSILON && dot <= 0 + FLT_EPSILON)
        return false;

    //Continueing
    FVector3 L{ m_Origin - ray.Origin };
    float t = Dot(L, m_Normal) / Dot(ray.Direction, m_Normal);

    if (t < ray.tMin || t > ray.tMax)
        return false;

    //Intersection point on triangle
    FPoint3 intersectionPoint = ray.Origin + t * ray.Direction;
   
    //Check first edge
    FVector3 edgeA = m_Vertices[1] - m_Vertices[0];
    FVector3 toIntersection{ intersectionPoint - m_Vertices[0] };

    bool isInside = (Dot(Cross(toIntersection, edgeA), m_Normal) > 0);
    if (isInside)
    {
        //Check second edge
        FVector3 edgeB = m_Vertices[2] - m_Vertices[1];
        toIntersection = intersectionPoint - m_Vertices[1];

        isInside = (Dot(Cross(toIntersection, edgeB), m_Normal) > 0);
        if (isInside)
        {
            //Check third and last edge
            FVector3 edgeC = m_Vertices[0] - m_Vertices[2];
            toIntersection = intersectionPoint - m_Vertices[2];

            isInside = (Dot(Cross(toIntersection, edgeC), m_Normal) > 0);
            if (isInside)
            {
                //Ray is intersecting 
                if (!isShadowCheck)
                {
                    hitRecord.HitPos = intersectionPoint;
                    hitRecord.tValue = t;
                    hitRecord.Color = m_Color;
                    hitRecord.Normal = m_Normal;
                }
                return true;
            }
        }
    }
    return false;
}
Sphere Hit Check
bool Sphere::Hit(const Ray& ray, HitRecord& hitRecord, bool isShadowCheck) const
{
	//Analytic solution
	FVector3 sphereToRay{ ray.Origin - m_Origin };

	float A = Dot(ray.Direction, ray.Direction);
	float B = Dot(2 * ray.Direction, sphereToRay);
	float C = (Dot(sphereToRay, sphereToRay)) - (m_Radius * m_Radius);

	float discriminant = (B * B) - (4 * A * C);
	if (discriminant > 0)
	{
		float sqrdDiscriminant = sqrt(discriminant);
		float T = (-B - sqrdDiscriminant) / (2 * A);

		if (T < ray.tMin)
		{
			T = (-B + sqrdDiscriminant) / (2 * A);
		}

		if (T > ray.tMin && T < ray.tMax)
		{
			if (!isShadowCheck)
			{
				hitRecord.HitPos = ray.Origin + T * ray.Direction;
				hitRecord.Normal = (hitRecord.HitPos - m_Origin) / m_Radius;
				hitRecord.Color = m_Color;
				hitRecord.tValue = T;
			}

			return true;
		}
	}
	return false;
}
Plane Hit Check
bool Plane::Hit(const Ray& ray, HitRecord& hitRecord, bool isShadowCheck) const
{
	float dot = Dot(ray.Direction, m_Direction);
	if (!isShadowCheck)
	{
		if (m_CullMode == CullMode::Backface && dot > 0)
			return false;
		else if (m_CullMode == CullMode::Frontface && dot < 0)
			return false;
	}
	else
	{
		if (m_CullMode == CullMode::Backface && dot < 0)
			return false;
		else if (m_CullMode == CullMode::Frontface && dot > 0)
			return false;
	}

	float T = Dot((m_Origin - ray.Origin), m_Direction) / dot;
	if (T > ray.tMin && T < ray.tMax)
	{
		if (!isShadowCheck)
		{
			hitRecord.HitPos = ray.Origin + T * ray.Direction;
			hitRecord.Normal = m_Direction;
			hitRecord.Color = m_Color;
			hitRecord.tValue = T;
		}

		return true;
	}
	else
	{
		return false;
	}
}

Contributors

Credits to Matthieu Delaere, a lecturer at Howest DAE for writing the base files (math library timer, color structs, SDL window). 

1. The first row of triangles is non-metal half rough using the Lambert Diffuse BRDF and the Cook-Torrance Specular BRDF.

2. The second row of spheres is non-metal and goes from rough to smooth (left to right) using the Lambert Diffuse BRDF and the Cook-Torrance Specular BRDF.

3. The third row of spheres is metal and goes from rough to smooth (left to right) using the Lambert Diffuse BRDF and the Cook-Torrance Specular BRDF. The reason why the third sphere looks pitch black is because of the fact it is a pure smooth metal, and there are no reflections implemented in this ray tracer, so it cannot technically emit any surrounding environment color.