Day 52 - 🧪 Experiment - Writing a BRDF from Scratch (Part 2)

General / 18 July 2026

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.

Day 51 - 🧪 Experiment - Writing a BRDF from Scratch

General / 17 July 2026

Build it yourself and you stop guessing what it's actually doing.

Instead of relying on the engine's built-in shading model, I wanted to write a physically-based BRDF by hand, term by term, to see where my understanding held up and where it didn't. This is part one: diffuse, Lambertian, and ambient. Specular and roughness come in the next post.

Albedo

I started with a flat color value as the base. For now, this is treated as a Lambertian material: fully diffuse, no specular yet.

vec3 albedo = vec3(0.8, 0.25, 0.0);


Lambertian

Next I added the Lambertian lighting model, the industry standard for diffuse and a clean starting point before adding more complex terms. Added a general light direction to drive it.

vec3 lambertian(float inNoL, vec3 inAlbedo, vec3 inLightColor, float inLightIntensity)
{
  return (inAlbedo / PI) * inLightColor * inLightIntensity * inNoL;
}


Hemisphere Ambient

To fake a skylight and bounce effect, I added a hemisphere ambient term. It uses the global world-space normal direction and interpolates between a sky color and a ground bounce color.

float ambient_intensity = 0.75;
vec3 ambient_light_color = vec3(0.5, 0.6, 1.0);
vec3 ambient_bounce_color = vec3(0.4, 0.3, 0.25);
vec3 ambient = mix(ambient_bounce_color, ambient_light_color, fWorldNormal.y * 0.5 + 0.5) * ambient_intensity;



Combining

Now to merge the Lambertian output with the ambient term. A straight addition would overbrighten the whole surface. Ambient is an indirect effect, so it should only affect areas that aren't already hit by direct light. I take the inverse of the light term to mask out those lit areas, keeping the exposure controlled.

vec3 out_color = lambertian(NoL, albedo, light_color, light_strength); // Lambertian
out_color += (1.0 - NoL) * albedo * ambient;                           // Ambient
gl_FragColor = vec4(pow(out_color, vec3(1.0 / gamma)), 1.0);           // Gamma Correction


Up next

This gets the diffuse side solid. The next post adds specular and roughness, which is where the BRDF gets more interesting and where most of the math lives.


© 2026 Stefan Groenewoud. All views are my own, not those of my employer.

Day 50 - 💬 Take - (My) Best Resources for Graphics Math

General / 15 July 2026

Real-Time Rendering taught me how things actually work. That's worth more than it sounds.

The resource I keep coming back to is Real-Time Rendering by Tomas Akenine-Möller, Eric Haines, and Naty Hoffman. The fourth edition is at realtimerendering.com — the same site that's been an industry reference hub for years. It covers the real-time pipeline with actual math: BRDFs, lighting models, shadow algorithms, PBR theory. Written for practitioners, not theoreticians.

Writing the posts on IBL and PBR compliance for this series forced me back to first principles. This is where the answers were.


Why it clicked

Most resources explain the formula and move on. Real-Time Rendering explains the derivation: where it comes from, what assumptions it makes, and what breaks when those don't hold. Understanding what the geometry term is correcting for changed how I read shader code. When something looks wrong at grazing angles, I now know where to look.

I treat it as a reference rather than something to read cover-to-cover. Go deep on the sections you need, come back when something doesn't make sense in production.


The production papers

Real-Time Rendering covers the foundations. SIGGRAPH production presentations show those applied under real constraints.

Real Shading in Unreal Engine 4 (Brian Karis, Epic Games, SIGGRAPH 2013) is probably the most cited real-time PBR paper. It covers the split-sum approximation for IBL, the Cook-Torrance implementation UE4 ships with, and the specific choices Epic made when adapting theory to a shipped engine.

Technical Art of Uncharted 4 covers how Naughty Dog pushed their materials model beyond textbook. The gap between "physically correct" and "what shipped" is instructive.

The Technical Art of The Last of Us Part II is the companion read: same studio, similar thinking, concrete tradeoffs between physical accuracy and art direction.

The Rendering of The Callisto Protocol (GDC 2023, Striking Distance Studios) is worth adding to the list; it shows the same principles applied under tighter console performance budgets, with a strong focus on skin shading and volumetric lighting.

Reading a paper alongside the RTR chapter on the same topic is worth doing at least once. It makes the abstract concrete quickly.


Additional resources

If you want to understand why the roughness slider does what it does but don't have much shader experience yet: start with blog.demofox.org, then come back to RTR when you want the full picture rather than just the isolated formula.

If you're writing shaders or debugging BRDF behavior: RTR is worth the investment.

Desmos helps with the math — plot functions interactively and see what a parameter change actually does to a curve. I used it constantly when working through the NDF and masking-shadowing sections. Physicallybased.info is a solid companion for real-world material reference values.


© 2026 Stefan Groenewoud. All views are my own, not those of my employer.

Day 49 - 📖 Learning - Week 7 Reflection

General / 15 July 2026

Block 4 in earnest: dot product, Fresnel, GGX, the Smith G term. The math behind every PBR shader, written out properly.

I took a week off since I was traveling and used the break to step away from the daily writing rhythm.


What clicked

It was a good refresher to go deeper into the math. I'd explained the dot product and GGX before at a surface level, but writing them out properly with the actual derivations and shader code, clarified where my understanding had gaps.

I also tried to incorporate more visuals this week to demonstrate the math behind some posts more concretely. The Fresnel post benefitted from it; showing F0 behavior at grazing angles is easier with a diagram than a paragraph.


What flopped

I'm starting to question whether ArtStation is the right platform for more technical posts. The traction is lower than earlier in the series, which could be a few things: the audience skews toward portfolio work rather than technical reading, or the audience for this depth of content is just smaller in general, or both. I do not know the answer to that though.


Into next week

The BRDF posts so far covered individual terms in isolation. Next week looks at how they integrate in an actual shader: writing a BRDF from scratch and visualizing the result. Also starting to wrap things up toward the end of the challenge, which I'm ready for.

© 2026 Stefan Groenewoud. All views are my own, not those of my employer.

Day 48 - ⚡️ Quick - Smith Masking-Shadowing

General / 14 July 2026

The G term is the unglamorous half of the BRDF. It's not where the interesting visual work happens, which is probably why it gets the least attention.


What It Solves

At grazing angles, surfaces have a tendency to blow out. Without G, the BRDF's denominator drives energy up faster than the specular lobe can compensate, and surfaces start emitting light they shouldn't. You've probably seen it: a low-roughness material at a shallow viewing angle that flares unnaturally bright along its silhouette. That's what happens when G is wrong or missing.

The cause is physical. At low angles, microfacets start blocking each other. A facet that would otherwise contribute to the reflection might be hidden from the viewer (masking) or blocked from the light source (shadowing). G accounts for both.

Smith (1967) showed that masking and shadowing can be treated independently, splitting G into two identical single-direction functions -- one for the view direction, one for the light:

What Ships in Real-Time

The exact Smith-GGX formulation involves a square root per term. Epic's UE4 approximation avoids it by remapping roughness to a k value:

The two k values are not interchangeable -- IBL and direct lighting need different remappings. In GLSL:

float G1_SchlickGGX(float NdotV, float k) {
    return NdotV / (NdotV * (1.0 - k) + k);
}

float G_Smith(float NdotV, float NdotL, float roughness) {
    float r = roughness + 1.0;
    float k = (r * r) / 8.0;
    return G1_SchlickGGX(NdotV, k) * G1_SchlickGGX(NdotL, k);
}


Practical Takeaway

If a material flares bright at grazing angles or the specular response looks implausible at low viewing angles, G is the first place to check. Visualize NdotV and NdotL in the shader and verify they're reaching the G term correctly. The separable Smith form used above is standard for real-time; more accurate height-correlated variants exist (Heitz, 2014) and are worth knowing for reference implementations.


© 2026 Stefan Groenewoud. All views are my own, not those of my employer.

Day 47 - ⚡️ Quick - Normal Distribution Function GGX

General / 06 July 2026

The NDF is how many microfacets are pointing the right way. GGX is the distribution that matched real surfaces better than anything before it, and its shape explains why specular highlights look the way they do.


What the D Term Controls

The Cook-Torrance specular BRDF has three terms:


D is the Normal Distribution Function and the most influential of the three. It determines the shape and size of the specular highlight.

The model treats a surface as covered in microscopic facets, each acting like a tiny mirror. A facet contributes to the reflection only when its normal m aligns with the half-vector h - the bisector between light direction l and view direction v. The NDF describes how many facets are oriented that way at a given roughness.

Sharper NDF → fewer aligned facets → smaller, brighter highlight.
Broader NDF → more facets spread across a wider cone → larger, dimmer highlight.


Why GGX Replaced Beckmann

Beckmann and Blinn-Phong both cut off too fast. A real specular highlight has a bright peak, then a long gradual falloff. That abrupt cutoff is what makes a highlight look like a texture stuck on the surface instead of actual reflected light.

GGX (also called Trowbridge-Reitz) has heavier tails:


The squared denominator decays slowly away from the peak, bright core, soft halo, which matched real-world measurements better than anything before it.


Practical Takeaway

Your roughness slider doesn't map linearly to α. Most engines remap it as α = roughness² internally, which makes the low end of the slider feel more gradual. Roughness 0 collapses the NDF to a point (perfect mirror); 1.0 spreads it across the hemisphere.

GGX is what your engine is running. The bright-core-long-tail shape is what PBR materials are calibrated against. If a highlight cuts off too abruptly, the masking-shadowing G₂ term is usually the culprit, not the NDF. More on that in Day 50-ish.


© 2026 Stefan Groenewoud. All views are my own, not those of my employer.

Day 46 - ⚡️ Quick - Cross Product and Normals

General / 04 July 2026

The cross product takes two vectors and returns a third perpendicular to both. That is how normals exist, and knowing that makes a surprising number of rendering quirks make sense.

source: wikipedia


What It Returns

Given two vectors a and b, the cross product a × b produces a vector pointing perpendicular to the plane they define. The direction follows the right-hand rule: curl your fingers from a toward b and your thumb points the way the result goes.

The magnitude scales with the area of the parallelogram the two inputs form - useful in some contexts, but for normals you normalize immediately after.


How It Gets Used

The most direct application is computing a face normal from triangle edges. Take two edges, cross them, normalize: face normal done.

Tangent space construction works the same way. To sample a normal map correctly, shaders need a tangent vector and a bitangent to build the local coordinate frame. Those come from the UV edge directions crossed against the surface normal. It's why tangent space normals can go wrong across UV seams or mirrored UVs: the tangent vectors don't line up cleanly on both sides.


The Winding Order Gotcha

Swap the order and the result flips: a × b and b × a point in opposite directions. This is the source of most "why is my normal backwards" problems. In practice it comes down to winding order: whether a triangle's vertices go clockwise or counter-clockwise from the camera. Flip the winding, flip the cross product, flip the normal.

It's also why back-face culling works at all. The engine checks which way the face normal points, and if it's facing away from the camera the triangle gets skipped.


Practical Takeaway

  • Cross product returns a vector perpendicular to both inputs. Normalize after.
  • Swap the input order and the result flips. Winding order determines which direction you get.
  • Tangent space depends on it too: UV seam errors are often a sign the tangent vectors are inconsistent across edges.

© 2026 Stefan Groenewoud. All views are my own, not those of my employer.

Day 45 - ⚡️ Quick - Fresnel Effect

General / 02 July 2026

Look at any surface at a grazing angle and it becomes more reflective. That is Fresnel. It is in every PBR shader, and getting it wrong is one of the clearest signals a material is not physically based.


What Fresnel Is

Pick up any object near you. Look at it straight on, then tilt it away until you're viewing nearly parallel to the surface — it gets brighter. Every surface does this, no matter the material. That's the Fresnel effect.

The physics: a surface is an interface between two media. When light hits it, some reflects and the rest refracts. The fraction that reflects depends on the angle. At normal incidence it's at its minimum — that minimum is called F0. At grazing it climbs toward 1 for every surface at every frequency. No exceptions.


F0 - The Anchor

F0 is the specular reflectance at perpendicular viewing. For a dielectric in air:

Most dielectrics are low: water at 0.02, skin at 0.028, plastic and glass around 0.04–0.05. The specular is achromatic — surface color comes from the diffuse response, not reflection.

Metals are different. Their F0 is high (0.5 or above) and tinted across the spectrum. Gold's F0 is (1.02, 0.78, 0.34) in linear — that warm characteristic reflection is F0, not a tint on top of it. Day 21 - 🔬 Deep dive - PBR Compliant Work covers what that physically means.



Schlick's Approximation

The full Fresnel equations aren't practical per pixel. In every PBR shader I've worked in, the Schlick approximation handles it:

At n·l = 1 (straight on) you get exactly F0. At n·l = 0 (grazing) you reach 1. The pow(5) keeps the curve near F0 across most angles and only kicks hard near 90°. The n·l here is the same dot product from Day 43 - 🔬 Deep dive - Dot Product in Rendering.

float3 FresnelSchlick(float NdotL, float3 F0)
{
    return F0 + (1.0 - F0) * pow(1.0 - NdotL, 5.0);
}


Practical Takeaway

Fresnel isn't only an artistic choice, it's a physical constraint every real surface follows. A PBR shader handles it automatically if F0 is set correctly.

  • Dielectrics: F0 between 0.02 and 0.05 covers almost everything. Values outside that range need a reason.
  • Metals: F0 is the specular color. It goes in the albedo map with metalness at 1.0. Not 0.9, not 0.5.
  • The forbidden zone: Linear F0 between ~0.2 and 0.45 doesn't correspond to any real substance. Flag it.
  • The grazing rise is free: You don't paint it in or control it. Set F0 correctly and Schlick handles the rest.


© 2026 Stefan Groenewoud - All views are my own, not those of my employer.

Day 44 - ⚡️ Quick - Lerp

General / 01 July 2026

Think of it like Photoshop's opacity slider between two layers: at 0 you see one, at 1 you see the other, anywhere in between you get a blend. Lerp, Linear Interpolation, is that same operation as a shader node. In shaders, the blend factor t can be a constant, a mask texture, an animated value, a dot product result, whatever drives the blend you need.

The math:
result = a * (1 - t) + b * t

t=0 returns A, t=1 returns B. Most shader tools (Unity Shader Graph, Unreal Material Editor, HLSL's lerp()) follow this convention. You may also see it written as a + t * (b - a), mathematically identical, just rearranged.

Watch the range. Lerp has no built-in clamp. If t goes below 0 or above 1, the output extrapolates beyond A or B, colors can exceed 1.0 and blow out, or drop below 0 and behave unexpectedly depending on your output target. For color blends and masks, add a saturate on t before the node. If you're blending direction vectors or deliberately extrapolating, unclamped is fine.

When the transition feels too mechanical, reach for smoothstep instead, it runs t through a smooth curve before the blend, so the result eases in and out rather than crossing at a constant rate.

© 2026 Stefan Groenewoud - All views are my own, not those of my employer.

Day 43 - 🔬 Deep dive - Dot Product in Rendering

General / 01 July 2026

The dot product shows up constantly in shaders, usually as a node you drop in without thinking too hard about it. Here is what it actually does and why it is useful.


What It Is

The dot product measures how much two vectors point in the same direction. The result is a single scalar: 1 when they are perfectly aligned, 0 when they are perpendicular, and -1 when they point in opposite directions.

In a shader, that scalar becomes a mask, a value you can use to drive blends, control effects, or make decisions based on geometry and view angle.

source: Unity - example of what World Space Normals and Dot-product can achieve in shaders.


Masking by Surface Angle

The most common use is blending a feature onto surfaces that face a particular direction. The classic example: moss on the upward-facing faces of a rock.

float3 upVector = float3(0, 0, 1); // world up
float mask = dot(upVector, worldSpaceNormal);
mask = saturate(mask);

The dot product returns 1 on faces pointing straight up, 0 on vertical faces, and negative values on downward-facing geometry. Saturate clamps everything below 0 away, leaving a clean mask. No matter how you rotate the asset, the moss always lands on the faces that point upward, the math follows the geometry.


Checking Camera Facing

A less obvious but equally useful application: determining whether a surface is facing toward the camera or away from it.

Compare the world-space normal against the view direction vector. If both vectors point away from the camera, they are in the same direction and the dot product is positive. If the surface is facing toward the viewer, the normal and view vector are opposed, the result is negative. Multiply by -1 and saturate to convert that into a usable [0, 1] mask.

This is useful for controlling effects that should only appear on visible surfaces, rim lighting, edge detection, or suppressing reflections on back-facing geometry.


Be Consistent

The result is only meaningful if both vectors are in the same space. Mixing a world-space up vector with a tangent-space normal will produce incorrect output, and the error won't always be obvious. Before wiring up a dot product, confirm which space each input is in and convert if needed.


Practical Takeaway

The dot product is unclamped by default. For masks that feed into blends or lerps, always add a saturate or clamp afterwards, values below 0 or above 1 will produce unexpected results downstream. If you want a soft transition rather than a hard cutoff, pipe the result into a smoothstep before using it as a mask.


© 2026 Stefan Groenewoud, All views are my own, not those of my employer.