# Issue with specular light at highly oblique angles with Blinn-Phong

I'm having an issue with my basic Blinn-Phong renderer, when looking at objects at very oblique angles:

I don't think this is an issue with my code, although I'll post my fragment's GLSL below. Rather, this seems to be a necessary consequence of cutting the specular component of the lighting to zero when dot(Normal, Light) <= 0. (Which everyone tells you to do.) But doing that means there will be this discontinuity at the terminator. Removing the clamping leads to other issues; there's no more visible seam, but now the specular highlight continues around to the dark side of the sphere.

Is there a simple way around this, or is this just an unavoidable downside of the Blinn-Phong model?

# EDIT

TL;DR: It seems like this isn't just a downside of the Blinn-Phong model.

I did some more research into BRDFs and found this paper: A New Ward BRDF Model with Bounded Albedo and Fitting Reflectance Data for RADIANCE, which discusses the shortcoming of the Ward model, specifically around high grazing angles (exactly my issue!), and how they tweaked the model to fix it. Ward is an anisotropic model, but it can be simplified to be isotropic, and when you do that to their implementation-ready form from page 22, you get:

$\frac{1}{\pi\alpha^2}\;exp\left(\frac{1}{\alpha^2}-\frac{\langle\vec{l}+\vec{v},\vec{l}+\vec{v}\rangle}{\alpha^2\langle\vec{l}+\vec{v},\vec{n}\rangle^2}\right)\frac{\langle\vec{l}+\vec{v},\vec{l}+\vec{v}\rangle}{\langle\vec{l}+\vec{v},\vec{n}\rangle^4}$

I plugged this into my code (updated below), aaaannnnnd... no dice. It looks pretty in the normal cases, but exhibits the same failure modes at the edge, and some even more spectacular ones beyond the edge (even worse than Blinn-Phong):

Note: both models are using the "shiny" param, but they mean different things to each model. The original screenshots were with shiny=.8, for Ward I had to turn it down to .1.

#version 150

#extension GL_ARB_conservative_depth : enable

in Frag {
vec3 color;
vec3 coord;
vec3 center;
float R;
};

out vec4 color_out;
layout (depth_greater) out float gl_FragDepth;

uniform mat4 VIEW;
uniform mat4 PROJ;

const vec3 gamma = vec3(1.0 / 2.2);
const float ambientPower = .15;
const float diffusePower = .75;

const bool PHONG = false;
const float specHardness = 60.0;
const float shiny = .1;

const bool WARD = true;

void main() {
// Find intersection of ray (given by coord) with sphere
vec3 eyeNormal = normalize(coord);
float b = dot(center, eyeNormal);
float c = b * b - (dot(center, center) - R * R);
if (c < 0.0) {
}
vec3 point = (b - sqrt(c)) * eyeNormal;
// Redo depth part of the projection matrix
gl_FragDepth = (PROJ[2].z * point.z + PROJ[3].z) / -point.z;

// Lighting begins here

// The light dir is in world-space, unlike the others, so we have to project it to view space.
// The direction (0, 1, 0) corresponds to the 2nd column. By the properties of the view matrix
// (the 3x3 part is an orthogonal matrix), this is already normalized.
vec3 lightNormal = VIEW[1].xyz;
vec3 normal = normalize(point - center);
float diffuse = dot(lightNormal, normal);

float specular = 0.0;
if (PHONG) {
// Have to reverse sign for eyeNormal so it points out
vec3 halfway = normalize(lightNormal - eyeNormal);
specular = diffuse <= 0.0 ? 0.0 : pow(max(0.0, dot(halfway, normal)), specHardness);
} else if (WARD) {
const float PI = 3.14159265359;
const float alpha = .15;
const float invAlpha2 = 1 / (alpha * alpha);
// Would move this computation to CPU and pass invAlpha2 as uniform if alpha were a parameter
const float cFactor = invAlpha2 / PI;

// Have to reverse sign for eyeNormal so it points out, note this is *unnormalized*
vec3 halfway = lightNormal - eyeNormal;
float dotP = dot(halfway, normal);
float invDot2 = 1 / (dotP * dotP);
float semiNormalizedInvDot = dot(halfway, halfway) * invDot2;
// Note: You can't factor the exp(invAlpha2) part out as a constant term,
// you'll blow out the floating-point range if you try.
specular = cFactor * exp(invAlpha2-invAlpha2*semiNormalizedInvDot) * semiNormalizedInvDot * invDot2;
}
diffuse = max(0.0, diffuse);
vec3 colorPre = (ambientPower + diffusePower * diffuse) * color
+ specular * shiny * vec3(1);

color_out = vec4(pow(colorPre, gamma), 0);
}

• One thing I noticed is you’re using the diffuse value in the specular computation. It’s possible that the discontinuity is because your ternary comparison. Specular reflection has nothing to do with diffuse color, it’s only the property of the view direction and reflect direction.
– Rish
Aug 18, 2020 at 3:23
• Maybe I didn't make it clear enough in my question, but the discontinuity is definitely because of that. However, you need the ternary comparison (or equivalent) because otherwise the specular highlight continues on to the unlit hemisphere, and with certain lighting models you eventually get wild artifacts (see the 3rd screenshot). The comparison against "diffuse" is just a cheap way of checking whether it's the lit side of the sphere. Aug 19, 2020 at 6:26

TL;DR: Multiply your specular value by dot(normal, lightNormal). (And do clamp that dot product to a minimum of 0!)

I, and I suspect (from seeing all the tutorials out there) many others, have been doing this all wrong.

I was using the Bidirectional Reflectance Distribution Function (AKA BRDF) directly to calculate specular intensity. However, the BRDF actually defines a differential quantity that is meant to be plugged into a radiometric integral. Crucially, what is usually called the BRDF lacks a cos(θ) term that is part of the overall integral. If you have a stomach for the math, it is discussed in more detail here: http://www.pbr-book.org/3ed-2018/Color_and_Radiometry/Surface_Reflection.html#TheBRDF

When we're just dealing with point light sources, we don't need to evaluate the whole integral; the limit of the integral as a light source becomes a point goes to the integrand. But the cos(θ) term is still important.

In practice this means the shader needs a slight tweak. Results:

Above is Blinn-Phong, fixed. This is with the same shininess as before; because Blinn-Phong doesn't have a Fresnel term, the cos(θ) fix causes the intensity to fall off dramatically at grazing angles. But the discontinuity is gone, at least.

This is fixed Ward, with the same shininess as the 2nd image before. Fresnel reflection (roughly, higher reflection at more oblique angles) is modeled in Ward, so this looks good. And no discontinuity!