July 19, 2026
To achieve a toon look in 3D rendering, the most common way is to threshold the lighting. It transforms the soft color gradients into flat colors.
// The rendering equation becomes:
float Threshold = 0.0; // any value between 0 and 1
vec3 Radiance = BRDF * LightIntensity * step(Threshold, dot(Normal, LightDirection));
However, one might want to mix the 2D and 3D looks: big sharp highlights at low roughness and soft gradients at high roughness. It feels more modern than classic toon shading, and we can rely on PBR for most of our shading, making everyone's lives easier (closer to energy-conservation, easier integration into existing pipelines, physically-based material calibration...).
Many games have explored the combination of PBR and toon shading.
Another example is Spellcasters Chronicles, which I had the chance to work on. During development we tested many things, including this "Stylized GGX Shading" I'm describing here. In the end we landed on something else, but it still uses a mix of 2D and 3D looks.
The most common PBR model is the GGX Cook-Torrance microfacet model.
// GGX Trowbridge-Reitz NDF
float D(vec3 Normal, vec3 HalfVector, float Roughness)
{
float a = Roughness * Roughness;
float a2 = a * a;
float NdotH = max(dot(Normal, HalfVector), 0.0);
float NdotH2 = NdotH * NdotH;
float denom = NdotH2 * (a2 - 1.0) + 1.0;
return a2 / (PI * denom * denom);
}
By slightly modifying this equation, we can expand the specular highlights.
// Stylized GGX Trowbridge-Reitz NDF
float D(vec3 Normal, vec3 HalfVector, float Roughness, float SpecularSize)
{
float a = Roughness * Roughness;
float a2 = a * a;
float NdotH = max(dot(Normal, HalfVector), 0.0);
float NdotH2 = NdotH * NdotH;
float denom = max(SpecularSize * NdotH2 * (a2 - 1.0) + 1.0, 0.0001);
return a2 / (PI * denom * denom);
}
I made a Desmos graph to visualize what's happening (follow the link to play with its parameters).
The simple version has the benefit of being extra cheap (1 mul and 1 max), but it impacts rough materials too much, making them appear overly bright.
// Stylized GGX Trowbridge-Reitz NDF
float D(vec3 Normal, vec3 HalfVector, float Roughness, float SpecularSize, float GGXMatching)
{
float a = Roughness * Roughness;
float a2 = a * a;
float NdotH = max(dot(Normal, HalfVector), 0.0);
float NdotH2 = NdotH * NdotH;
// The GGXMatching parameter controls how much the stylized NDF matches the GGX one at high roughness. In practice, 4 or 8 works well.
float denom = max((SpecularSize * pow(1.0 - a, GGXMatching) + 1.0) * NdotH2 * (a2 - 1.0) + 1.0, 0.0001);
return a2 / (PI * denom * denom);
}
Here is the graph animating the roughness (at high roughness the curves flatten).
Here is the final look under varying roughness, metalness and specular size.
One might feel like this stylized GGX look is not "toon" enough.