This repository contains several basic lighting models implemented in Unity using HLSL (High-Level Shading Language).
Each lighting model is explained with its corresponding mathematical formulation and a breakdown of how it's implemented
in code. Feel free to click on the image below to watch the demo video before we get started!
This project demonstrates different lighting techniques used in real-time computer graphics. The models implemented cover a range from simpler lighting techniques like Lambertian and Phong Lighting to the more advanced ones like Oren-Nayar and Cook-Torrance.
All shaders are written in HLSL and designed to be used in Unity’s Built In Rendering Pipeline. Below is an overview of the implemented lighting models, along with the mathematical concepts and code snippets for each.
The Lambertian lighting, named after Johann Heinrich Lambert, is the most fundamental model for simulating diffuse reflection in computer graphics. It assumes that light is scattered uniformly in all directions from each point on the surface, which makes it ideal for modelling matte materials such as unpolished surfaces like chalk or clay. The model’s simplicity lies in the fact that the intensity of reflected light is determined solely by the cosine of the angle between the surface normal and the direction of incoming light, a principle known as Lambert’s Cosine Law.
In this example, the lighting will be calculated in the vertex shader.
Where:
half3 n = UnityObjectToWorldNormal(v.normal); // Converting vertex normals to world normals.
half3 l = normalize(_WorldSpaceLightPos0.xyz); // Normalises the light direction vector.
float Id = kD * saturate(dot(n, l)); // saturate() to clamp dot product values between 0 and 1 to prevent negative light intensities.
finalColour = Id * _DiffuseColour * _LightColor0; // Multiplying I with the surface's colour and the light's colour to get the final observed colour.
Gouraud shading, named after the French computer scientist Henri Gouraud, enhances Lambertian lighting by incorporating specular and ambient terms from Phong lighting. However unlike Phong Lighting, lighting calculations are performed at the vertices in the vertex shader, and the resulting colour values are interpolated across the surface of the polygon during rasterisation, which happens in the fragment shader.
While efficient, Gouraud shading can lead to poor shading results, especially in low-poly models, due to the per-vertex lighting calculation. This approach may cause the loss of finer lighting details, such as sharp specular highlights, since those details are "smoothed out" through interpolation across the surface.
Where:
float3 worldPos = mul(unity_ObjectToWorld, vx.vertex).xyz; // Transform vertex position to world space
half3 n = UnityObjectToWorldNormal(vertex.normal); // Transform normal to world space
half3 l = normalize(_WorldSpaceLightPos0.xyz); // Get normalized light direction
half3 r = 2.0 * dot(n, l) * n - l; // Calculate reflection vector
half3 v = normalize(_WorldSpaceCameraPos - worldPos); // Get normalized view direction
float Ia = _k.x; // Ambient intensity
float Id = _k.y * saturate(dot(n, l)); // Diffuse intensity using Lambert's law
float Is = _k.z * pow(saturate(dot(r, v)), _SpecularExponent); // Specular intensity
float3 ambient = Ia * _DiffuseColour.rgb; // Ambient term
float3 diffuse = Id * _DiffuseColour.rgb * _LightColor0.rgb; // Diffuse term
float3 specular = Is * _LightColor0.rgb; // Specular term
float3 finalColor = ambient + diffuse + specular; // Combine all lighting components
o.color = fixed4(finalColor, 1.0); // Set the final output colour
Phong lighting builds upon the same mathematical principles as Gouraud-Phong lighting but differs in its implementation within shaders. While Gouraud shading performs the lighting calculation per vertex in the vertex shader and then interpolates the resulting colours across a triangle, Phong shading interpolates surface normals across the triangle and performs the lighting calculations per pixel in the fragment shader. This allows for a smoother and more detailed lighting effecs, paricularly for specular highlights and shiny surfaces.
By performing per-pixel lighting, Phong shading offers a visually more realistic appearance, especially when dealing with curved surfaces or complex lighting conditions. However, the per-pixel lighting calculation coms at a higher computational cost, especially for large triangles or high-resolution renderings.
Same as in Gouraud shading
// Same as in Gouraud shading but calculations are performed in the fragment shader.
Blinn-Phong shading is a refined version of Phong shading that optimises the calculation of specular highlights. Instead of using the reflection vector like Phong shading, it calculates a halfway vector, which is the vector between the light direction and the view direction. This makes the specular calculation more efficient, reducing the computational cost while maintaining similar visual quality, especially for smooth surfaces.
This lighting model also improves the visual quality of specular reflections as documented here in detail.
Where:
half3 h = normalize(l + v); // Compute halfway vector (both l and v are already normalised)
...
float Is = _k.z * pow(saturate(dot(h, n)), _SpecularExponent); // Calculate the specular intensity using Blinn-Phong model
...
Flat shading with Blinn-Phong lighting works by assigning a single normal to an entire triangle, rather than per vertex. In this case, the normal is calculated using the cross product of the screen-space derivatives of the triangle’s world positions, ensuring it represents the entire face. This normal is then used in the Blinn-Phong lighting model to calculate the lighting for all pixels within the triangle. Since the same normal is applied across the entire surface, the specular highlights and shading appear flat and uniform for each triangle, giving the model a faceted look while still using Blinn-Phong's lighting principles.
Where:
float3 worldNormal = normalize(cross(ddy(i.worldPos), ddx(i.worldPos))); // Calculate the flat normal for the triangle using screen-space derivatives
half3 n = normalize(worldNormal); // Ensure the normal is a unit vector for lighting calculations
...
Toon shading, influenced by Japanese anime and Western animation, uses stylised lighting to give 3D graphics a 2D, hand-drawn look. The math behind this shader builds, too, on the Blinn-Phong lighting model, with smoothstep functions to create clear yet smooth lighting bands. For additional lights (directional, point, and spot), I used an additive approach to allow multiple sources to interact naturally without heavy performance costs. For the extra stylised look, I used a Fresnel-based approach for rim lighting which I learned from here, where light wraps around the edges of an object based on the viewing angle, creating a subtle highlight that enhances shape definition. Combined with textures, normal maps, and outlines for clear borders, this setup brings a polished comic-book feel. The full repository will be available soon.
Smoothstep Diffuse and Specular
Rim Lighting (Fresnel)
Final Colour Calculation with Rim Light
// Smoothstep Diffuse Calculation
...
Id = smoothstep(0.005, 0.01, Id); // Apply smoothstep to diffuse term
// Smoothstep Specular Calculation
...
Is = smoothstep(0.005, 0.01, Is); // Apply smoothstep to specular term
// Fresnel Rim Lighting
fixed rimDot = 1 - dot(v, n); // Fresnel-based view-angle calculation
float rimIntensity = rimDot * pow(NdotL, _RimThreshold); // Adjust intensity with threshold
rimIntensity = smoothstep(_FresnelPower - 0.01, _FresnelPower + 0.01, rimIntensity); // Smooth transition for rim
half4 rim = rimIntensity * _LightColor0; // Apply Fresnel effect as rim light
// Final Colour Calculation
half3 lighting = ambient + diffuse + specular + rim; // Combine diffuse, specular, and rim lighting
...
The Oren-Nayar model is a reflection model developed by Michael Oren and Shree K. Nayar to extend simple Lambertian shading for rough, diffuse surfaces. Unlike Lambertian shading, which assumes light scatters evenly in all directions, Oren-Nayar calculates how surface roughness affects light scattering, using parameters like the viewer's angle, light direction, and surface roughness, σ. This model captures realistic diffuse behaviour, especially for materials like cloth or plaster, where surface microstructures cause directional variation in brightness. By introducing these variables, Oren-Nayar enables more realistic shading in computer graphics for non-smooth, matte surfaces. My implementation directly mirrors the standard calculations outlined on Wikipedia, using the same parameters and cosine terms to enhance realism in shading effects for rough, matte surfaces.
The reflected light
and the direct illumination term,
Where:
and
...
float3 E0 = _LightColor0.rgb * saturate(dot(n, l));
// Calculate angles of incidence and reflection
float theta_i = acos(dot(l, n));
float theta_r = acos(dot(r, n));
// Project light and view vectors onto the tangent plane to calculate cosPhi, the cosine of the azimuthal angle (difference in orientation) between projected light and view
float3 Lproj = normalize(l - n * NdotL);
float3 Vproj = normalize(v - n * NdotV + 1); // +1 to remove a visual artifact
float cosPhi = dot(Lproj, Vproj);
// Determine max and min angles for roughness calculation
float alpha = max(theta_i, theta_r);
float beta = min(theta_i, theta_r);
float sigmaSqr = _sigma * _sigma;
// Calculate C1, C2, C3
float C1 = 1 - 0.5 * (sigmaSqr / (sigmaSqr + 0.33));
float C2 = cosPhi >= 0 ? 0.45 * (sigmaSqr / (sigmaSqr + 0.09)) * sin(alpha)
: 0.45 * (sigmaSqr / (sigmaSqr + 0.09)) * (sin(alpha) - pow((2.0 * beta) / UNITY_PI, 3.0));
float C3 = 0.125 * (sigmaSqr / (sigmaSqr + 0.09)) * pow((4.0 * alpha * beta) / (UNITY_PI * UNITY_PI), 2.0);
// Compute direct illumination term L1 and interreflected term L2
float3 L1 = _DiffuseColour * E0 * cos(theta_i) * (C1 + (C2 * cosPhi * tan(beta)) + (C3 * (1.0 - abs(cosPhi)) * tan((alpha + beta) / 2.0)));
float3 L2 = 0.17 * (_DiffuseColour * _DiffuseColour) * E0 * cos(theta_i) * (sigmaSqr / (sigmaSqr + 0.13))
* (1.0 - cosPhi * pow((2.0 * beta) / UNITY_PI, 2.0));
// Final light intensity
float3 L = saturate(L1+L2); // Clamped between 0 and 1 to prevent lighting values from going negative or exceeding 1.
...
The Cook-Torrance model is a reflection model developed by Robert Cook and Kenneth Torrance in 1982, designed to simulate specular reflection on rough, shiny surfaces with a level of realism that surpasses simpler models like Phong or Blinn-Phong. While those earlier models treat surfaces as smooth, Cook-Torrance assumes a surface is made up of countless microscopic facets, each acting as a tiny mirror. This model calculates specular reflection based on factors like viewing angle, light direction, and a roughness parameter, similar to Oren-Nayar but tailored to specular highlights.
Core components include the Fresnel effect, which increases reflectivity at grazing angles; the Geometry Term, which accounts for shadowing between facets; and the Normal Distribution Function (NDF), describing the spread of facet orientations, with rougher surfaces creating broader specular highlights. By combining Cook-Torrance for specular reflection with Oren-Nayar for diffuse reflection, this approach captures the nuanced behaviour of light on both matte and glossy surfaces. My implementation mirrors standard calculations as described here, including the Fresnel-Schlick approximation and GGX distribution, for lifelike specular effects on rough surfaces.
The Cook-Torrance model implements the specular term so:
Where:
F is the Fresnel term of this equation, approximated using Schlick's approximation for performance reasons. For simplicity, the index of refraction
D is the Beckmann distribution factor, where
and G is our geometric attenuation term, where
... // Oren-Nayar above...
float NdotH = saturate(dot(n, h)); // Dot product between normal and halfway vector, clamped to [0,1]
float a = acos(NdotH); // Convert NdotH to an angle in radians
float m = clamp(sigmaSqr, 0.01, 1); // Clamp roughness value to avoid extreme cases
float exponent = exp(-tan(a) * tan(a) / (m * m)); // Exponent term for Beckmann distribution
float D = clamp(exponent / (UNITY_PI * m * m * pow(NdotH, 4)), 1e-4, 1e50); // Beckmann Distribution. Clamped to get rid of a visual artefact
// Base Fresnel reflectance (F0) calculated from the refractive index
float F0 = ((_RefractiveIndex - 1) * (_RefractiveIndex - 1)) / ((_RefractiveIndex + 1) * (_RefractiveIndex + 1));
// Fresnel term (F) using Schlick's approximation
float F = F0 + (1 - F0) * pow(1 - clamp(dot(v, h), 0, 1), 5);
// Geometry term (G) using Smith's approximation
float G1 = 2 * dot(h, n) * dot(n, v) / dot(v, h);
float G2 = 2 * dot(h, n) * dot(n, l) / dot(v, h);
float G = min(1, min(G1, G2)); // Final geometry term (G) as the minimum of G1 and G2
// Specular reflection component
float specular = ((D * G * F) / (4 * dot(n, l) * dot(n, v))) * _LightColor0;
...
This project is licensed under the MIT License. See the LICENSE file for details.