Specular is where the BRDF starts to earn its name.
Part 1 covered albedo, Lambertian diffuse, and hemisphere ambient. This part adds specular using GGX, then swaps the Lambertian model for Burley diffuse, which folds roughness into the diffuse response too.
From Lambertian to Burley
Instead of writing the diffuse result directly to out_color, I store it separately. With specular terms arriving, keeping them isolated makes the combining step cleaner to reason about.
Lambertian treats diffuse as constant regardless of viewing angle. Burley adds a Fresnel-based correction: it softens the diffuse response at grazing angles and adds a slight retroreflection on rougher surfaces. The difference is subtle, but since roughness was already going into specular, it made sense to have the diffuse side respond to it too.
vec3 Lambertian(float inNoL, vec3 inAlbedo, vec3 inLightColor, float inLightIntensity)
{
 return (inAlbedo / PI) * inLightColor * inLightIntensity * inNoL;
}
float F_Schlick(float inU, float inf0, float inf90)
{
 float Fc = exp2((-5.55473 * inU - 6.98316) * inU);
 return inf0 + (inf90 - inf0) * Fc;
}
// Disney Burley diffuse
float Burley(float NoV, float NoL, float LoH, float roughness) {
  float f90 = 0.5 + 2.0 * roughness * LoH * LoH;
  float lightScatter = F_Schlick(NoL, 1.0, f90);
  float viewScatter = F_Schlick(NoV, 1.0, f90);
  return lightScatter * viewScatter * (1.0 / PI);
}
// ...
vec3 diffuse = albedo * Burley(NoE, NoL, LoH, roughness) * light_color * light_strength * NoL;GGX Specular
The specular term is the product of three functions: D, V, and F. D is the Normal Distribution Function, which controls the shape of the specular highlight. V is the geometric visibility term, accounting for microfacets that shadow or occlude each other at grazing angles. F is Fresnel, which drives the grazing-angle brightness rise.
V_SmithGGX is included for reference. I used HCV instead, the height-correlated Smith-GGX formulation and a closer match to the Disney model.
float GGX(float inNoH, float inAlpha)
{
 float temp = inAlpha / max(1e-8, (inNoH * inNoH * (inAlpha * inAlpha - 1.0) + 1.0));
 return temp * temp / PI;
}
float F_Schlick(float inU, float inf0, float inf90)
{
 float Fc = exp2((-5.55473 * inU - 6.98316) * inU);
 return inf0 + (inf90 - inf0) * Fc;
}
float GFGL(float inNoX, float inAlpha)
{
 return -0.5 + 0.5 * sqrt(1.0 + inAlpha * inAlpha * (1.0 / (inNoX * inNoX) - 1.0));
}
float HCV(float inNoL, float inNoE, float inAlpha)
{
 inNoL = max(inNoL, 1e-3);
 inNoE = max(inNoE, 1e-3);
 float geo = 1.0 / (1.0 + GFGL(inNoE, inAlpha) + GFGL(inNoL, inAlpha));
 float normalization = 1.0 / (4.0 * inNoL * inNoE);
 return geo * normalization;
}
float V_SmithGGX(float NoV, float NoL, float a) {
  float a2 = a * a;
  float GGXL = NoV * sqrt((-NoL * a2 + NoL) * NoL + a2);
  float GGXV = NoL * sqrt((-NoV * a2 + NoV) * NoV + a2);
  return 0.5 / (GGXV + GGXL);
}
void main()
{
 vec3 light_color  = vec3(1.0, 1.0, 1.0);
 vec3 albedo    = vec3(0.8, 0.25, 0.0);
 float pRoughness  = 0.5;
 vec3 specColor   = vec3(0.2, 0.2, 0.2);
 float roughness = pRoughness * pRoughness; // perceptual roughness remapping
 float D = GGX(NoH, roughness);
 float V = HCV(NoL, NoE, spec_alpha);
 float F = F_Schlick_Fast(LoH, max(max(specColor.x, specColor.y), specColor.z));
 vec3 specular    = vec3(D * V * F) * specColor;
 vec3 specular_light = specular * light_color;
 vec3 diffuse = albedo * Burley(NoE, NoL, LoH, roughness) * light_color * light_strength * NoL;
 vec3 out_color = diffuse;
 out_color += specular_light;
 out_color += (1.0 - NoL) * albedo * ambient;
}Result
With roughness driving both the Burley diffuse and the GGX specular, the material responds consistently across the full parameter range. The clip below cycles through: black albedo, specular only, albedo plus specular, then with ambient added. Light position and roughness are both animated to verify behavior across conditions.
© 2026 Stefan Groenewoud. All views are my own, not those of my employer.


