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.

Day 42 - 📖 Learning - Week 6 Reflection

General / 29 June 2026

Block 3, second part: the more technical side of pipelines.

This week had more numbers in it than the previous one. LOD math, texel density, mesh instancing, texture compression. That shift felt right. The first half of this block was mostly conceptual and process-driven; this half had to justify the decisions with actual values. That's a different kind of writing and a harder one, but I think it's more useful.


What clicked

The texel density posts worked well, though oddly only one of the two got significantly more reads than the other, even though I split them intentionally. That split came from a lesson I took from the previous block: trying to pack too much into a single post made them harder to read and harder to write. Breaking it into two kept each post focused on one thing. Long posts don't necessarily mean more value, and there's a real risk that length discourages people from finishing.


What flopped

Success is relative. I try not to fixate on view counts or likes; the benchmark I keep coming back to is whether the content would have been useful to me earlier in my career. Where I may have misjudged this week is the technical depth. Some of this material might be too specific for where most of the audience (ArtStation). Interesting content, possibly on the wrong platform for some of it.


Into next week

Block 4: math and code. This is the stretch I've been building toward. The posts so far have referenced the underlying math without going into it properly. Next week that is going to change, hopefully.


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