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
- Linear Algebra (Vector- and Matrix math)
- Ray Intersections (Plane, Sphere, Triangle)
- Orthographic/Perspective Camera
- Orthonormal Base (ONB)
- Rendering Equation
- Hard Shadows
- BRDF's (Lambert, Phong, Cook-Torrance)
- Physically Based Rendering (PBR)
- OBJ Parser
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
//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));
}
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;
}
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 };
}
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;
}
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;
}
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.