Draft Work in progress. Wording, structure, and claims may still change. Feedback welcome. ← Back to roadmap

05 pbr

Trading a guess for a model

Swapping the Blinn-Phong lobe for a Cook-Torrance metallic-roughness BRDF: GGX distribution, Smith geometry, Schlick Fresnel, and what energy conservation actually buys you. The G-Buffer's two spare scalar slots become roughness and metallic, so the whole change is a shader plus a material record. Ends on a live A/B against Blinn-Phong in the same scene.

Trading a guess for a model

Blinn-Phong asks you what the highlight should look like. Cook-Torrance asks you what the surface is made of.


TL;DR

  • Blinn-Phong’s two knobs, shininess and specular strength, are not measurements of anything. You tune them until the frame looks right, and the number you land on is a fact about your taste.
  • It also does not conserve energy, and it has no idea what a metal is. The (shininess + 8) / 8 factor sitting in the shader is somebody’s apology for the first of those.
  • Cook-Torrance is a microfacet model: a distribution term, a masking term, and a Fresnel term. Its parameters are roughness and metallic, both of which you can look up for a real material rather than dial in.
  • The whole swap was a fragment shader and a material record. No new pass, no new attachment, no new pipeline, and not one line of the render graph moved.
  • The G-Buffer already had two unused alpha channels, and roughness and metallic went into them. Which one goes where is decided by precision, and it is written down in one testable place.
  • PbrMaterial joined the material union rather than replacing BlinnPhongMaterial, so both models are live at once and the comparison is a dropdown instead of a rebuild.
  • Cook-Torrance comes out about three times dimmer than Blinn-Phong for the same light. That is the correct answer, and the A/B has to be honest about it.

The number that means nothing

Here is the material that has been shading everything in this project since post 3.

public static BlinnPhongMaterial Default(MaterialId id, string name) =>
    new(id, name,
        Albedo: new Vector3(0.6f, 0.6f, 0.6f),
        SpecularStrength: 0.5f,
        Shininess: 32f,
        ...);

Shininess 32.

I want to sit on that number for a second, because I typed it without thinking and it took me an embarrassingly long time to notice what it is. It is not a measurement. There is no instrument that reports 32, no table in a materials handbook where you look up brushed aluminium and find 32 in the shininess column, and no physical quantity it approximates. It is an exponent I raised a dot product to because at 8 the highlight was a blob and at 128 it was a pinprick, and 32 was the one that looked like a surface.

Specular strength 0.5 is the same kind of number, arrived at the same way.

This is not a criticism of Blinn-Phong, which was a good answer to the question it was asked in 1977. The question was “what is a cheap function whose output looks like a highlight”, and the answer is genuinely cheap and genuinely looks like a highlight. But it means that every material in the engine is a record of what I thought looked good at the moment I dragged the slider, and there is no external thing any of those numbers is accountable to.

That is a problem if you ever want to open a file somebody else made.


What Blinn-Phong actually computes

The version in the lighting shader is the standard one, and it still runs, because this post does not delete it.

// include/lights.glsl, the legacy branch, unchanged in behaviour.
float shin = max(roughnessToShininess(s.roughness), 1.0);
float specularStrength = s.metallic;

vec3 diffuseTerm = NoL * s.albedo * lightColor * intensity;

vec3 halfDir = normalize(lightDir + s.view);
float shinBP = shin * 4.0;
float norm = (shinBP + 8.0) / 8.0;
float spec = norm * pow(max(dot(s.normal, halfDir), 0.0), shinBP);
vec3 specular = lightFacing * spec * lightColor * intensity * specularStrength;

return (diffuseTerm + specular) * attenuation;

Two terms added together. The diffuse term is Lambert: the cosine of the angle between the surface normal and the light, times the surface colour. The specular term raises the dot product of the normal and the half vector to a power, and the power is the shininess.

A higher exponent makes the falloff steeper, which makes the bright region smaller, which reads as a smoother surface. That is the entire physical intuition available, and it is a description of the output rather than of the surface.

Look at norm, though, because that line is the interesting one.

float norm = (shinBP + 8.0) / 8.0;

Raw Blinn-Phong gets dimmer as you raise the exponent, because concentrating the lobe into a smaller area without compensating for the area loses light. So somewhere along the way people started multiplying by a factor derived from the exponent to put the brightness back. That factor is an admission. It says: this function does not conserve energy on its own, and here is a correction that makes it roughly conserve energy, derived by integrating the lobe and inverting the result.

Once you have written a normalization factor, you have accepted that the amount of light leaving a surface should relate to the amount arriving. And once you have accepted that, you are most of the way to asking whether the whole lobe should be derived from that principle rather than patched into agreement with it.


Three things the model cannot do

It cannot be looked up. This is the one that actually forces the change. Every parameter in the material is a number I invented, so there is no file format in the world that can hand this engine a material. glTF ships base colour, metallic and roughness, because those are the parameters of a model that a specification could agree on. It does not ship a shininess exponent, because whose?

It does not conserve energy, and the patch only covers one term. The normalization above fixes the specular lobe’s total, and then the diffuse term is added to it at full strength regardless. A surface with specular strength 1 and a bright highlight reflects its diffuse colour and a normalized specular lobe, which together can exceed the light that arrived. You will not see this as a visual bug. You will see it as scenes that need their lights turned down, and as materials that look right alone and slightly hot in a group.

It has no concept of a metal. This is the one that is visible immediately once you know to look. In Blinn-Phong the specular colour is the light’s colour, always. So a gold surface is a yellow diffuse term with a white highlight, and that is not what gold does. Real metals have no diffuse reflection worth speaking of: the light that is not immediately reflected is absorbed by the free electrons rather than scattered back out. And their reflection is tinted, because a metal’s reflectance varies with wavelength. Gold reflects gold.

You can fake it by tinting the specular strength per material, and people did, for years. But then “specular strength” means one thing for a dielectric and a different thing for a metal, and you have two models wearing one parameter.


Microfacets, and the three terms

The Cook-Torrance model starts from a picture rather than from a curve fit.

A surface that looks smooth at our scale is rough at a scale much smaller than a pixel, and it is made of microscopic facets each of which is a perfect mirror. What we see as a highlight is the aggregate reflection of every facet that happens to be oriented to bounce the light at us. A polished surface has most of its facets pointing the same way, so only a tight cone of directions reflects the light and the highlight is small. A rough surface has facets pointing everywhere, so a wide cone does and the highlight is broad.

That is not a description of a highlight. It is a description of a surface, and the highlight falls out of it.

The specular term of the model is three factors over a normalizing denominator:

        D(h) * G(l, v, h) * F(v, h)
f_spec = ───────────────────────────
              4 * (n·l) * (n·v)

D is the distribution: of all the microfacets, what fraction are oriented along the half vector, which is the only orientation that can bounce this light into this eye. G is geometry: of those facets, what fraction is neither hidden behind another facet from the light’s direction nor from ours. F is Fresnel: how reflective the surface is at this particular viewing angle, which climbs toward total reflection as you approach grazing.

Each factor answers a question about the surface, and roughness is an input to two of them. Here is the whole thing as it exists in the engine.

// include/brdf.glsl

// GGX / Trowbridge-Reitz normal distribution. alpha = roughness^2 is the
// disney/UE remapping: it makes the perceptual slider linear-ish.
float distributionGGX(float NoH, float alpha) {
    float a2 = alpha * alpha;
    float d = NoH * NoH * (a2 - 1.0) + 1.0;
    return a2 / max(PI * d * d, 1e-7);
}

// Smith height-correlated masking-shadowing, returned already divided by the
// BRDF's 4*NoL*NoV denominator (Heitz 2014).
float visibilitySmithGGXCorrelated(float NoV, float NoL, float alpha) {
    float a2 = alpha * alpha;
    float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
    float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
    return 0.5 / max(lambdaV + lambdaL, 1e-5);
}

// Schlick's approximation to Fresnel: reflectance climbs to white at grazing.
vec3 fresnelSchlick(float VoH, vec3 f0) {
    float f = pow(clamp(1.0 - VoH, 0.0, 1.0), 5.0);
    return f0 + (1.0 - f0) * f;
}

Three functions, about fifteen lines, and two of them deserve a note.

alpha = roughness * roughness is not physics, it is ergonomics. GGX’s width parameter behaves in a way that puts all the interesting variation at the low end, so a linear roughness slider would spend three quarters of its travel doing nothing visible. Squaring it spreads the change out across the slider. The convention comes from Disney’s 2012 course notes and glTF assumes it, which matters later: an asset authored against that remapping and rendered without it is an asset rendered too smooth.

The visibility term is already divided by the BRDF’s denominator. The 4 * (n·l) * (n·v) in the formula above never appears anywhere in this shader, because Heitz’s 2014 formulation folds it into the Smith term and a good deal of the algebra cancels when you do. The payoff is that the specular term reads as D * Vis * F with nothing left over, which is the version you can look at and check. It is worth knowing this is why, because the first time you compare this file against the equation in a paper, the missing denominator looks like a bug.


Metallic is the parameter that does the work

The distribution and geometry terms take roughness. Fresnel takes f0, the reflectance when you look straight down at the surface, and f0 is where the metal problem gets solved.

// include/lights.glsl, the Cook-Torrance branch.
vec3 f0 = mix(DIELECTRIC_F0, surfaceColor, s.metallic);

float alpha = s.roughness * s.roughness;
float D   = distributionGGX(NoH, alpha);
float Vis = visibilitySmithGGXCorrelated(NoV, NoL, alpha);
vec3  F   = fresnelSchlick(VoH, f0);

vec3 specular = D * Vis * F;

// Energy split: what Fresnel reflects cannot also refract, and what a
// metal absorbs never comes back out as diffuse.
vec3 kD = (vec3(1.0) - F) * (1.0 - s.metallic);
vec3 diffuse = kD * surfaceColor / PI;

return (diffuse + specular) * NoL * radiance;

DIELECTRIC_F0 is vec3(0.04). Almost every non-metal reflects about four percent of light head-on, which is a narrow enough range that treating it as a constant is a better trade than exposing it as a slider nobody can set meaningfully. (It is not exactly constant, it is a function of the index of refraction, and there is an extension that makes it a parameter. That is not this post.)

For a metal, f0 becomes the base colour, and the base colour stops being a diffuse albedo and becomes a specular tint. One parameter, two meanings, and metallic is the lerp between them. That sounds like the “two models wearing one parameter” complaint I made about specular strength three sections ago, so it is worth saying why it is different: the lerp is between two physically distinct behaviours that a real surface can genuinely be between, at a boundary or a partially oxidised patch, and both endpoints are defined. Specular strength had one behaviour and two interpretations.

Then the energy split.

vec3 kD = (vec3(1.0) - F) * (1.0 - s.metallic);

Two sentences in one line. (1 - F) is: light that was reflected at the surface did not go inside, so it cannot come back out as diffuse. (1 - metallic) is: a metal has no diffuse term at all. The diffuse and specular terms now come out of one budget, and the total leaving a surface cannot exceed the total arriving.

That is what energy conservation actually buys you, and it is less about any single frame looking better than about frames staying consistent. A material that conserves energy behaves the same in a dim scene and a bright one, which means you can light a scene without re-tuning its materials, and you can put two objects next to each other without one of them looking wrong.


The change is a shader and a record

Here is the part I find most satisfying, and it is an argument about architecture rather than about shading.

The G-Buffer did not move.

Post 3 wrote three attachments: position, normal, albedo. Each one is four channels, and the fourth channel of the normal target and the fourth channel of the albedo target were both unused, because a world-space direction needs three and a colour needs three. Two spare scalars per pixel, sitting there since post 3, and Cook-Torrance needs exactly two.

// RenderLab.Scene/MaterialPacking.cs, as this post leaves it.
// It has since grown two more fields, which is a later post's problem.
public static PackedMaterial Pack(MaterialParams material) => new(
    Albedo:      material.Albedo,
    NormalAlpha: material.Roughness,
    AlbedoAlpha: material.Metallic);

Which scalar goes in which slot is not arbitrary, and the reason is precision.

gNormal is R16G16B16A16_SFLOAT, so its alpha is a 16-bit float. gAlbedo is 8-bit per channel. Roughness is the one that gets swept across a surface and across a slider, and a roughness ramp is exactly where 8-bit banding is visible, so roughness takes the 16-bit channel. Metallic in practice is almost always 0 or 1, because a surface is usually either a metal or it is not, and 8 bits is a great deal more than a value like that needs.

I would not normally write a paragraph about which alpha channel holds which float. It is here because of what happened next: this codec is now read by four shading modes, two shading passes, and a conformance harness, and every one of them has to agree about it. So it is a pure function in the scene assembly with tests on it, rather than a convention living in two shaders and a comment. The one bug it exists to prevent is silently swapping the two, which produces a perfectly plausible image of the wrong material.

And the render graph, from post 4:

new RenderPassDeclaration("Lighting",
    Inputs: [
        new PassInput(gPosition, ResourceUsage.ShaderRead),
        new PassInput(gNormal,   ResourceUsage.ShaderRead),
        new PassInput(gAlbedo,   ResourceUsage.ShaderRead),
    ],
    Outputs: [new PassOutput(hdrColor, ResourceUsage.ColorAttachmentWrite)]),

Unchanged. Not adapted, not extended. The lighting pass reads three resources and writes one, which is everything the graph ever knew about it, and swapping the BRDF inside it is not a fact the graph has any opinion about.


A second material, not a replacement

The material side is where a decision had to be made rather than a line written.

The obvious move is to change BlinnPhongMaterial into PbrMaterial, write a migration that converts every saved material on disk, and be done. I did not do that, and the reason is the one in the project’s own rules: the older posts have to stay reproducible. A migration is a lossy one-way rewrite, and after it runs, the scene that produced post 3’s screenshots no longer exists anywhere.

So PbrMaterial joined as a second case of the union.

public abstract record MaterialAsset(MaterialId Id, string Name) { ... }

public sealed record BlinnPhongMaterial(..., float SpecularStrength, float Shininess, ...)
    : MaterialAsset(Id, Name);

public sealed record PbrMaterial(..., Vector4 BaseColor, float Roughness, float Metallic, ...)
    : MaterialAsset(Id, Name);

This is the thing a discriminated union is for, and it is the second time in this project that adding a case has cost less than I expected. Nothing that consumed a material had to learn there were two kinds, because the only consumer that cares is the one translating into the G-Buffer’s vocabulary, and translating is what it was already for.

// RenderLab.Scene/MaterialPacking.cs
public static MaterialParams ToParams(MaterialAsset asset) => asset switch
{
    PbrMaterial pbr => new MaterialParams(...),
    BlinnPhongMaterial bp => new MaterialParams(
        Color01.UnsafeFrom(bp.Albedo),
        BlinnPhongConversion.ToRoughness(...),
        UnitInterval.UnsafeFrom(Math.Clamp(bp.SpecularStrength, 0f, 1f))),
    _ => MaterialParams.Default,
};

The conversion lives at the pass boundary, which is to say it happens every frame and is never written to disk. A material authored as Blinn-Phong in post 3 is still a Blinn-Phong material in the file, and opens as one. It just gets read through a lens on the way to the GPU.


Only one of the two axes needed any arithmetic

This is the small discovery in the phase, and it is the kind that only shows up once you write the conversion down.

I expected to need two mappings, one per parameter. I needed one.

Shininess to roughness is real arithmetic, through the standard microfacet match:

// RenderLab.Scene/BlinnPhongConversion.cs
public static UnitInterval ToRoughness(Shininess shininess)
{
    float alpha = MathF.Sqrt(2f / (shininess.Value + 2f));
    float roughness = MathF.Sqrt(alpha);
    return UnitInterval.UnsafeFrom(Math.Clamp(roughness, MaterialParams.MinRoughness, 1f));
}

Shininess 32 comes out at roughness 0.49, which is a pleasing sanity check on a number I picked by eye a year ago.

Specular strength to metallic needs nothing. Both are on the unit interval, both mean “how much does this surface reflect specularly rather than diffusely”, and both point the same direction. They are the same axis under two names, so the value crosses untouched.

I had been thinking of the two models as two vocabularies that happen to describe the same thing. They are not, quite. They share one axis exactly and disagree about how to parameterize the other, and the disagreement is entirely about how the highlight spreads. Which, once you say it out loud, is the only thing Blinn-Phong’s exponent was ever controlling.

The inverse mapping exists too, because the Inspector has a combo that converts a material between the two vocabularies, and without it a project authored before this change had no path to a roughness slider at all. Going back is lossy in a way worth stating: the exponent saturates, so every roughness below about 0.30 converts to “as sharp as Blinn-Phong goes here”. And the metallic-roughness-only fields, emissive and the rest, are dropped when you convert away from PBR, which is what switching to a vocabulary with no word for something means.


The A/B, and the number I did not want to publish

ShadingMode gained a fourth case, which is an integer the shader branches on.

public enum ShadingMode
{
    Lambertian = 0,
    Phong = 1,
    BlinnPhong = 2,
    CookTorrance = 3,
}

So the comparison is a dropdown in the Visualization panel, on one frame, with the same lights and the same geometry and the same material record read two different ways. No rebuild, no second scene, no pair of screenshots taken an hour apart with something else accidentally different.

[image: the same scene, the same frame, the shading dropdown moved from Blinn-Phong to Cook-Torrance]

[image: a roughness sweep, five spheres from 0.05 to 1.0 at metallic 0, then the same row at metallic 1]

That second row is the one that makes the metallic parameter obvious. At metallic 0 you get a grey plastic that goes from polished to chalky. At metallic 1 you get the same five spheres shaded as metal, and the base colour has moved from the diffuse term into the reflection, so the row is coloured by whatever the environment is rather than by the material’s own grey.

Now the honest part.

Cook-Torrance comes out about three times dimmer than Blinn-Phong for the same light intensity, and the first time I flipped the dropdown I assumed I had broken something.

I had not. The difference is 1 / PI.

// The 1/PI is Lambert's normalisation, which the legacy modes below
// omit. Cook-Torrance is therefore about 3x dimmer for the same light
// intensity - the honest number, not a bug in the A/B.
vec3 diffuse = kD * surfaceColor / PI;

A Lambertian surface scatters incoming light over a hemisphere, and integrating the cosine over that hemisphere gives you PI, so a diffuse BRDF that conserves energy has a 1 / PI in it. The legacy modes do not have one. They never did, because they were never trying to conserve anything, and their light intensities were chosen to look right without it.

So the A/B is not a fair fight at equal intensity, and I could have hidden that by scaling the Cook-Torrance path by PI to make the dropdown look like a pure quality comparison. That would be a lie told to make a demo look better. The correct response is to leave the factor where it belongs and turn the lights up, and to say in the post that the scene’s light intensities changed when the model did.

This is the kind of thing I would rather write down than discover twice.

One more number with a reason: roughness is clamped to a floor of 0.045 on both the CPU and the GPU.

public const float MinRoughness = 0.045f;

A perfectly smooth microfacet distribution is a delta function, and GGX expresses that by dividing by alpha to the fourth, which at roughness zero is a division by zero and a pixel of infinity. The floor is the smallest roughness that still produces a finite highlight. It is mirrored as MIN_ROUGHNESS in the shader, and the two have to stay in agreement, which is the sort of duplication I have made peace with because the alternative is passing a constant through a uniform to say something that will never change at runtime.


What this bought, and what comes next

The frame did not get dramatically prettier. If you had shown me the two screenshots without telling me which was which, I would have picked the right one, but I would not have been excited about it.

What changed is what the materials are.

Before this, every material in the project was a record of my own taste, tuned per scene, accountable to nothing. After it, a material is two numbers that describe a physical surface, on scales that mean the same thing in every scene and, more to the point, in every renderer that uses this model.

Which is the actual prize, and it is not a rendering improvement at all. It is that the engine now speaks a vocabulary somebody else’s file can be written in. glTF 2.0’s core material is base colour, metallic and roughness, in exactly this parameterization with exactly this roughness² remapping, because glTF standardised on the same model. The importer could not previously have read a glTF material properly even if I had written the code, because there was nowhere to put the values. Now there is, and the gap between “the importer reads this” and “the renderer is correct” becomes something an external test suite can measure.

That is several posts away. Two things come first, and the order is not arbitrary.

Normal mapping is next, because the surface this post gave a physical response to is still geometrically flat between vertices, and a roughness parameter describes detail below the pixel while a normal map describes detail above it. That one grows the vertex layout, which nothing in this project has done since post 2.

Image-based lighting is after it, and it has to be after the BRDF rather than before. The environment term is an integral of the BRDF over all incoming directions, precomputed, so the integrator is written against a specific BRDF. Building it before this post would have meant writing it against Blinn-Phong and then writing it again.

The ambient term in the engine right now is still a hemispheric constant: a sky colour, a ground colour, and a lerp on the normal’s Y. It is the last hand-tuned guess in the shading path, and it is the exact same kind of number as shininess 32.


Final thoughts

I keep coming back to the same shape in this project, and this phase is the cleanest example of it so far.

The change that looked like it should be big was small, and the change that looked trivial was where the thinking went. Swapping the BRDF was fifteen lines of shader. Deciding that the two material models should coexist rather than one replacing the other, and that the conversion between them belongs at a pass boundary rather than on disk, took considerably longer and is the decision I would defend hardest.

The first one is arithmetic. The second one is about what the engine is allowed to forget.

A migration deletes history to make the present tidy, and a translation at a boundary keeps both and costs a switch expression evaluated once per material per frame. When the cost of keeping something is that small, the interesting question stops being “can we afford to keep it” and becomes “what do we lose if we do not”. Here, what we would have lost is the ability to run the comparison in the first place. The A/B dropdown that makes this post’s images possible only exists because the old model was still there to compare against.

Energy conservation is the headline. The thing I would actually take away is that a physically based parameter is one you can look up, and a tuned parameter is one only you can defend.