Day 27 - 📖 Learning - Week 4 Reflection

General / 09 June 2026

Halfway through. What landed, what did not, and what I am going to do differently in the second half. I move the Reflection post up by one day since I was not able to collect all images and text for what I actually had planned, so hopefully I am able to post it tomorrow.

Block 2 was heavy. Not in a bad way, but heavy in the way that happens when you dig into a topic and suddenly realize how much you didn't actually know about it.

Two weeks on real-time rendering: wider surface area than it looks. I knew most of the terms, but this block was about pressure-testing that, and it cracked in plenty of places. I was also picking up LaTeX along the way. My math skills are poor at the best of times.

The more deeply you try to understand something, the less certain you feel about it. I'm starting to think that's just what real learning feels like.

More intense than the previous block: real-time rendering is broad and I was trying to cover a lot of it. Quick explainers kept ending up at 350+ words because I wanted to give the full picture: cause, context, tradeoffs, solutions. That's probably right, but it's a pacing problem. The question is whether to pull back or split topics across multiple posts. Not sure where to go with that next.


What landed

The posts that had visual examples held up better than the ones that were purely text. The gamma correction and IBL deep dives felt complete because there was something to anchor the explanation to. The specular aliasing post worked for the same reason. Where I had a diagram or a before/after comparison, the concept came across. Where I didn't, it just read like a long explanation you had to take on faith. Again, the tradeoff of writing at pace.


What flopped

A few posts tried to cover too much ground. The AO and bent normals post is the clearest example. It started as a quick format and ended up going two or three levels deeper than planned initially. The output was fine but it took far longer to write than it should have, and I'm not sure the extra length was worth the cost.


What I'll do differently

Split topics earlier rather than discovering mid-write that a post needs to be two posts. If the outline has more than three distinct ideas, it's probably two posts. I'd also rather have a short post with one good visual than a long post with none. That's the adjustment I plan going into the second half.

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

Day 26 - ⚡️ Quick - Light Temperature

General / 05 June 2026

Color temperature is not decoration. It tells the viewer what kind of light they are in, and mixing it wrong is one of the fastest ways to make a scene feel staged.


The Kelvin Scale in Practice

Color temperature is a standardized way to describe the color of a light source, expressed in Kelvins (K). Lower values are warm and orange; higher values are cool and blue. It's used across photography, rendering, display calibration, and even the light bulbs in your house.

Here are the values that actually matter in practice:

For rendering and photography, the two most important anchors are 5500 K (the neutral daylight standard for photography) and 6500 K (D65, the standard for display and color work). When in doubt, those are the reference points.


Warm vs Cool, When to Mix

The most compelling lighting rarely uses a single temperature — it uses two that contrast. A warm key light paired with a cool fill, or warm interior light against a cool exterior coming through a window: these feel real because that's how light actually behaves in the real world. The sun is warm, the sky is cool, and the two are almost always visible at the same time.

The general rule: pick a dominant temperature and let the secondary light contrast it. Golden-hour sun (around 3000–4000 K) against blue-sky bounce, or a tungsten interior lamp against the blue of dusk outside a window — that tension is what gives a scene depth.

source: pexels.com

What to avoid is unintentional mixing, where the temperatures are different but not for any particular reason. That is what makes a render feel off even when the geometry and materials are solid. The viewer can't name the problem, but the light doesn't add up.


Practical Takeaway

I keep my home office lights at a neutral tone (~5000 K) when I'm doing color work, so they don't skew my color reads. For reading or winding down I drop to around 3000 K — warm light is genuinely easier on the eyes in the evening.

For rendering and material work, I actually don't use a tinted monitor (e.g. f.lux) or any drastic colorshift in the editor while working — I want the most neutral read of the scene's colors possible. That said, I can see the value: locking your render lights to a known temperature and building your scene's palette around it is a shortcut to grounded, cohesive results.

In photography, color temperature was mostly a white balance concern — neutralizing the overall tint before any processing so you're starting from a clean baseline. The same logic applies to photogrammetry/photoscanned assets. When you shoot reference outdoors in direct sunlight, you don't want the time-of-day tint baked into your textures, because it won't match a neutral or differently-lit scene. Shoot in overcast or controlled light, or correct it out before importing.

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

Day 25 - 🧪 Experiment - Lighting with Only Image Based Lighting

General / 04 June 2026

No directional lights. No point lights. Just a cubemap. Here is what breaks, what holds up, and what surprised me.


The Setup

The goal was to keep everything simple; a bare minimum setup just to demonstrate the impact of IBL. I reinstalled Unreal Engine and had to pick up after many years of not being as active in it anymore.

  1. Found an interesting IBL I'd grabbed from the Unreal Marketplace years ago.
  2. Grabbed the Lighting Level from the Test Content and stripped out everything except a Skylight and a Directional Light (disabled for the first test), leaving a clean scene to start the experiment with.
  3. Tweaked the IBL intensity.
  4. Added a PostProcess volume.
  5. Removed the SkyAtmosphere setup. No dynamic time of day needed here, and I didn't want a feature that would significantly skew the results. Then kicked off a light bake.

IBL from marketplace, used in the scene.


Results

Soft, even light. No directionality, no shadows. All coming from the environment.


IBL only; direct lighting disabled.

Lighting pass only; direct lighting disabled.

Additional experiments


First bake with the wrong directional light color sampled.

Pass with the directional light color sampled from the actual IBL instead of eyeballing, for consistency sake.

Lighting pass only


A night of good sleep

After thinking about the process some more, I felt like I'd gone about it all wrong. The settings were off, the setup was fine, but I hadn't looked closely enough at the Sunny-16 rule. I set it to overcast, which puts exposure at f/8, then started balancing the intensity of the Skylight, since it's intensity-driven rather than lux-value-driven (or so I thought).


Sunny 16 rule: at f/8, 400 ISO and 1/400
In the previous image it might look a bit dark but turning the camera towards the light direction brightens the overall feel, as you typically see with lightly clouded skies. Head-on, it would still hurt your eyes.


What Worked

It produced a natural-looking result: the light bounces helped a lot. Simply rotating the IBL gave noticeable, subtle shifts in lighting that you just don't get with a uniform sky color.

Iteration was also faster than I remembered, compared to old versions of Unreal. Setting up and re-baking is significantly quicker than it used to be.

From the Unreal documentation: "Sky Lights use the pixel intensity multiplied by the light intensity result in a total luminance that is expressed in cd/m2 in HDR. For example, if the HDR pixels were thought of as a filter and those pixels ranged from 0 to 1.0 with the sky set to an intensity of 1000 cd/m2, the resulting luminance would be 1.0 * 1000 cd/m2."

I learned this the hard way, I only saw the Intensity slider and didn't realize it would translate to 1000 cd/m2, which is why the earlier experiments look a little off. I was able to verify the sky intensity (average) against 2k cd/m2 (2000 cd/m2), which helped make the system more consistent overall. Using the illuminance and luminance meter I could verify my values and confirm that any Exposure Compensation adjustments would hold up. Since the scene is overall darker and receives less light than a sunny day, you have to compensate slightly for overall intensity. After more tinkering and using the debug settings, I landed on f/12.5 (the IBL reads more as 'cloudy' than 'overcast') and dialed in the Exposure Compensation accordingly.


What Broke

IBL-only produced a very overcast tone, technically plausible, but not visually interesting on its own. Adding even a subtle Directional Light made a noticeable difference in bringing some life to the scene.

I also had to bump up the contrast on the IBL. Despite having plenty of bit depth, the initial result was very flat. A bit of contrast went a long way.

On the light values: overcast skies have a luminance of around 1,000–2,000 cd/m2 and an illuminance of roughly 1,000–10,000 lux (commonly 5,000 lux), while bright midday sun sits at roughly 100,000 lux. Unreal's Directional Light defaulted to 10 lux, which visually looked fine but felt off numerically, unless they mean k-lux. Setting it to the physically correct value blew out the render completely, so I ended up dividing it by 1000, not ideal, but it worked.


What I Would Do Differently

With more time, I'd dig deeper into the exposure settings and get a better understanding of what Unreal currently offers there. I'd also spend more time experimenting with different light types and intensities.

I'd also place more reflection probes to see how they affect the specular IBL. Everything defaulted to the global IBL here, which felt a bit out of place, though it did serve as a useful sanity check that the system was working correctly.


Conclusion

This experiment made me think about the implications for games. IBL-only produces static results. You could interpolate between states if you have skilled matte painters, but then your light bakes fall out of sync. I understand why studios want to reduce their reliance on static IBL: Time of Day basically can't change, and you lose the accuracy of volumetric cloud scattering and dynamic atmospherics that come with it.

Building everything procedurally in-engine is a significant investment in resources and compute time, but it gives you precise control over the exact look you want, say, 20% cloud coverage casting soft shadows with correct atmospheric influence. That said, in Unreal the IBL system is still used even alongside dynamic TOD systems; it just gets updated every few frames rather than being fully static. So it is in support of new features instead of being replaced completely.

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

Day 24 - ⚡️ Quick - Specular Aliasing

General / 03 June 2026

Your normal map looks fine in the viewport. Then the camera moves and the highlight shimmers. That is specular aliasing, and it is fixable.


What Causes It

Specular aliasing is a sampling problem. Your pixel shader evaluates the BRDF at one fixed point on screen per pixel. When a surface has high-frequency normals, the specular contribution can vary wildly across a tiny area. When the camera or object moves, that sample point shifts, and the highlight flickers. The smoother your material, the worse it is; a tight specular lobe amplifies every small normal deviation.

There are two distinct sources of this aliasing.

Normal map aliasing is what most people think of first. When a normal map mips down, a standard box filter averages the normals but the roughness map has no idea; it still holds whatever the artist painted. The result at distance is flat normals combined with very high gloss: mirror-like shimmering. Sledgehammer's Danny Chan framed it cleanly: normals and gloss both represent geometric information at different scales, and independently mipmapping them breaks their relationship.

Geometric specular aliasing happens even without normal maps. Dense meshes have normals interpolated across triangles, and near silhouettes that interpolation can overshoot, producing normals that point "behind" the surface, causing bright specular sparks. Vlachos at Valve flagged this in the GDC 2015 VR talk: the camera never stops moving in VR, so any temporal instability becomes immediately visible.


Why It Gets Worse at Grazing Angles

At steep viewing angles, anisotropic texture filtering samples a higher-resolution mip, one that hasn't had the roughness correction applied yet. The aliasing creeps back in exactly at the Fresnel peak. Ready at Dawn noted this explicitly: their technique breaks down at grazing incidence, where GGX deviates from the Gaussian assumption their maths relies on. They worked around it by forcing trilinear (not anisotropic) filtering on the roughness map, accepting some over-smoothing as the lesser evil.

A related problem: when normal map texels point away from the camera (n·v ≤ 0), the geometry term creates a sharp discontinuity. Ready at Dawn found it better to shade those pixels as front-facing rather than clamp to zero; the grazing falloff still looks correct, with no hard edge.


The Fix

There is no single universal solution, but the approaches cluster into a few families.

Gloss-from-normal-variance (offline precompute) is the pragmatic sweet spot for most shipped games. The core idea, from Toksvig (2004), is that the length of an averaged normal encodes how much variance the individual normals had. A flat normal map averages to a unit vector; a noisy one averages to something shorter. You map that shortening to a roughness increase at mip generation time, so each lower mip has roughness baked in that accounts for all the normals under that texel.

Sledgehammer (COD: WWII) built this specifically for GGX. They importance-sampled the GGX NDF across 255 gloss steps to generate a lookup table mapping gloss to "shortened normal length," then used that table during mip generation. Normal direction and gloss are stored separately (two-channel normal + one-channel gloss) since floating-point shortened normals were too memory-intensive.

Ready at Dawn (The Order: 1886) did the same thing but more rigorously, using a von Mises-Fisher distribution to fit an NDF per texel and convolving it with the BRDF in the frequency domain. The effective roughness formula is α' = sqrt(α² + 1/(2κ)) where κ is the vMF concentration. In practice the results are very similar to Toksvig. Both approaches have zero runtime cost. All the work is in the offline compositing pipeline.


Valve (Advanced VR Rendering, GDC 2015) tackled both problems. For normal maps, they stored a 2D anisotropic roughness value per mip (std dev of tangent normals in X and Y), which fits neatly into a DXT5 RGBA texture. For geometric aliasing on dense meshes, they compute a roughness floor from ddx/ddy of the interpolated vertex normal; if the normal changes fast across a pixel, roughness goes up to match. For silhouette sparkling specifically, they interpolate the vertex normal twice (standard and centroid) and pick the centroid version whenever the standard normal has over-interpolated past unit length.

LEAN / CLEAN mapping stores the full covariance of the normal distribution per texel, giving true anisotropic NDF filtering for any BRDF. Quality is excellent but memory and precision requirements are high; both Sledgehammer and Ready at Dawn evaluated it and passed.


Practical Takeaway

For most projects: generate roughness mips to account for normal variance. It's a pipeline change, not a shader change, and eliminates the worst of the shimmering at zero runtime cost. Pair it with the ddx/ddy geometric roughness term to cover dense meshes and silhouettes.

Artstyle is a real factor; a stylized game can tolerate more aggressive roughness floors than a photorealistic one. For VR, treat it as non-optional: aliasing that's mildly annoying on a monitor becomes actively unpleasant when the camera never stops moving and pixels-per-degree are already low.


Referenced Papers

  • Alex Vlachos, Valve: Advanced VR Rendering, GDC 2015
  • Danny Chan, Sledgehammer Games: Material Advances in Call of Duty: WWII, SIGGRAPH 2018
  • David Neubelt & Matt Pettineo, Ready at Dawn: Crafting a Next-Gen Material Pipeline for The Order: 1886, SIGGRAPH 2013
  • Michael Toksvig: Mipmapping Normal Maps, 2004


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

Day 23 - 🔬 Deep Dive - Tone Mapping

General / 02 June 2026

Your renderer works in linear light. Your display does not. Tone mapping is the bridge, and the choices you make there shape how everything else reads.


Where Tone Mapping Sits

Tone mapping sits near the end of the pipeline: render (linear) → exposure → post-processing → tone map → gamma encode → display.

Everything before the tone mapper operates on unbounded linear values. Everything after is clamped to the display's range. Post-effects like bloom and color grading need to run before it, on linear data, or they produce physically incorrect results. Bloom applied after tone mapping spreads less than it should because the bright pixels have already been compressed and no longer carry their actual intensity.


Why Tone Mapping Exists

Renderers accumulate light in linear space. Direct sunlight hitting a surface can reach around 100,000 cd/m²; the sun disk itself sits around 1,600,000,000 cd/m². A standard display outputs values between 0 and 1. Without tone mapping, anything above 1.0 clips to white and all the detail in bright areas disappears. The specular highlight on a car hood, the gradient across a lit wall. Gone.

Tone mapping is the function that remaps that unbounded range into something a display can show, in a way that still reads as light. The goal is not just compression. It is compression that preserves relative brightness relationships, retains detail in both highlights and shadows, and avoids the flat, washed-out look of a simple clamp.

The eye adapts logarithmically, far more sensitive to differences in shadow than in highlights, as explored in Day 18 - 🔬 Deep dive - Linear Luminance. A good tone mapper approximates that adaptation response, so the result feels close to how the eye would have read the original scene.


The Operators

Not all tone mappers are equal, and the differences matter more than they might seem.

Reinhard is the simplest: it divides each pixel's value by itself plus one (c / (c + 1)). The result is always between 0 and 1, and it never clips. The problem is that it compresses the entire luminance range uniformly. Midtone contrast suffers, and scenes tend to look flat and desaturated. It is useful for understanding the concept but rarely the right choice for production.

Filmic (Uncharted 2 / Hable) was developed by John Hable for Uncharted 2 and popularized through his Filmic Worlds blog. It is an S-curve with a controllable toe (shadow lift) and shoulder (highlight rolloff), tuned to approximate the look of photochemical film. It preserves more midtone contrast than Reinhard, and it was the first widely-used game operator that intentionally modeled filmic response. Many engines that say "filmic" are still running a variant of this.

ACES (Academy Color Encoding System) was developed by the Academy of Motion Picture Arts and Sciences as an industry-wide standard for film production. In game pipelines, what most engines expose is a simplified approximation of the ACES curve rather than the full ACES transform; the full system includes a Reference Rendering Transform (RRT) that converts scene-linear to a canonical image state, followed by an Output Device Transform (ODT) tailored to the target display. Unreal Engine uses a simplified fit of the ACES curve. The result is an S-shape that lifts deep shadows slightly, keeps midtones largely intact, and softly rolls off highlights into a warm, saturated range.

AgX is a more recent operator, now the default in Blender. It was designed to handle high-energy inputs (very bright lights, fire, emissive materials) more gracefully than ACES, avoiding the strong color shift in the highlights that ACES produces. It is gaining traction in offline rendering and starting to appear in real-time contexts.


The ACES Curve in More Detail

The highlight rolloff is the part most people notice first when switching to ACES. Bright areas do not blow out to pure white. Instead they compress into a warm, slightly desaturated range, which is why ACES gives skies, fire, and windows a distinctly filmic quality.

The color shift is not a bug. As luminance increases, the S-curve compresses the high end more aggressively, and the relative proportions of R, G, and B change in the process. The result is a warm cast in the highlights, similar to what happens on real film. It is an intentional aesthetic property, but it means your materials and lighting are being viewed through a curve that has an opinion. An emissive material tuned to look correct under ACES will look different under AgX or a neutral display.

The shadow lift is subtler. The toe of the ACES curve pushes near-black values slightly above zero, which can make very dark areas feel lifted rather than pure black. Whether that reads as cinematic or muddy depends on the scene.

old tonemapping
filmic tonemapping


Practical Takeaway

The tone mapper is not a post-process you add after the scene looks good. It is part of how your scene looks. Author and validate in the same tone mapping context your target platform will use. Switching operators at the end of a project is not just a color grade; it can fundamentally change how your materials and lighting read.

If your bright materials feel muddy or highlights are shifting color unexpectedly, check whether the tone mapper is responsible or whether the issue exists before it. Toggling the tone mapper on and off is a reliable diagnostic: it separates problems in the linear data from problems the curve is introducing.

One thing worth remembering from Day 18 - 🔬 Deep dive - Linear Luminance: because the eye adapts logarithmically, small numerical differences in shadow areas produce larger perceptual shifts than the same differences in highlights. Tone mappers behave the same way: the toe of the curve will amplify any noise or imprecision in your dark values more than you expect.

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



Day 18 - 🔬 Deep dive - Linear Luminance

Day 22 - 🔬 Deep Dive - How IBL Works

General / 01 June 2026

Image based Lighting turns a photograph of the world into a light source. Here's what actually happens between the cubemap and the final pixel.

Why Lighting From a Photo Works

Ambient lighting gives surfaces a uniform base color from all directions equally, a practical shortcut, but an obviously unrealistic one. In the real world, light arrives with wildly different intensities depending on where you look.

Image-based lighting replaces that flat approximation with a photograph of the actual environment. Every texel of the captured image functions as a light source, so instead of placing discrete lights by hand, the scene is illuminated by the world around it. Think of a VFX shot where a CGI element needs to match on-set footage, and IBL is what makes that integration feel effortless. For games it serves the same purpose: a quick way to establish a grounded, photoreal read without hand-placing dozens of lights.

That's what makes IBL feel different from traditional lighting setups. The environment contributes from every direction simultaneously, which is why IBL-lit scenes look grounded rather than staged.


source: marmoset

There are some limitations worth keeping in mind. Cubemaps for IBL need to be captured as HDR - typically 32-bit per channel EXR (FP32) or 16-bit half-float. Even then, extreme dynamic ranges can be a problem. A sunset sky with a visible sun is a common example: the contrast is high, and the capture process itself - cameras and HDR merge workflows - can't reliably capture the full range when the sun is in frame. The sun's luminance at noon is approximately 1,600,000,000 cd/m², which FP32 can store numerically, but getting there accurately from a real-world capture is the hard part.

For more info on capturing real life IBL footage: https://marmoset.co/posts/hdr-panorama-photography/

Building the Cubemap

The environment needs to be recorded as a spherical function - incoming radiance from every direction around a point. That data gets stored in one or more images, indexed by direction. This is environment mapping. It's one of the most powerful forms of environment lighting: more memory-intensive than other spherical representations, but simple and fast to decode at runtime. Critically, it can represent arbitrarily high-frequency signals just by increasing resolution, and capture any range of radiance by increasing bit depth - which is why HDR capture matters. A standard 8-bit texture would clip the sky and produce incorrect lighting integrals.

Several projection formats exist, each with different trade-offs.

Latitude-longitude (equirectangular) is the dominant exchange format. The reflected view vector is converted to spherical coordinates - longitude (azimuthal angle, 0 to 2π) and latitude (polar angle, 0 to π) - and mapped to UV:

Distortion is unavoidable when projecting a sphere onto a rectangle. The bigger problem is that the sample density is highly non-uniform: the poles are oversampled relative to the equator.

Sphere mapping derives the texture from the appearance of the environment as viewed orthographically in a perfectly reflective sphere. Photographing a chrome ball is the classic real-world capture method - the result is called a light probe. The mapping is view-dependent though, which limits its use, and a reflective sphere only captures the front hemisphere. In practice, sphere maps are typically assumed to operate in view space.

Cube mapping is the runtime standard. The environment is projected onto the six faces of a cube centered at the capture point, stored as six square textures with no wasted space. Synthetically, they're produced by rendering the scene six times with a 90° FOV; for real environments, equirectangular panoramas are reprojected. The key advantages are that it's view-independent, hardware-native, and indexing is trivial - the reflected view vector r is passed directly as a three-component texture coordinate, no trigonometry needed, and doesn't even need to be normalized.

Dual paraboloid mapping uses two textures, each covering one hemisphere via a parabolic projection. Octahedral mapping unfolds the sphere by cutting eight triangular faces and arranging them on a square or rectangle. It avoids the filtering artifacts that affect some other projections, and the L₁-normalized lookup is straightforward to implement in a shader.

Splitting Diffuse and Specular

The full IBL integral (incoming radiance × BRDF × cosine term over the hemisphere) is too expensive to evaluate per pixel. The solution is to precompute as much as possible, exploiting one key observation: diffuse and specular respond to the environment at very different frequencies.

Diffuse is low-frequency: only the general hemisphere energy matters, not the exact incoming direction. Specular is high-frequency and view-dependent: smooth surfaces pick out a narrow reflection cone; rough surfaces blur it.

Diffuse - irradiance and Spherical-harmonics

The diffuse component integrates incoming radiance convolved with a cosine-weighted kernel. The result is very smooth, which makes it ideal for compression. Spherical harmonics (SH) are the natural fit.

SH are an orthonormal basis defined on the sphere. Projecting the irradiance function onto each basis function gives a coefficient capturing how much of that frequency is present. The first three bands (L0 through L2, nine coefficients total) reconstruct diffuse lighting with very little error.

A key property is that the integral of the product of two functions equals the dot product of their coefficient vectors:

This means the diffuse irradiance integral reduces to a dot product at runtime, which is essentially free. Low-band SH avoids ringing (Gibbs phenomenon) because diffuse lighting is inherently smooth and well-represented by just a few coefficients.

Specular - prefiltered environment map

Specular is both roughness-dependent and view-dependent. A perfectly smooth surface samples a single reflected direction; increasing roughness blurs the reflection over a wider cone. This is represented as a mip chain of the environment map, where each level stores the environment pre-convolved at a different roughness value. Rough materials sample higher mips; mirror-like materials sample the base level.

The BRDF Integration Map

The split-sum approximation factors the specular integral into two independent parts. The prefiltered environment map handles the lighting side. The other part captures how the BRDF itself responds to a given roughness and viewing angle - independent of what the environment looks like.

This is baked into a 2D LUT, indexed by NdotV (cosine of the angle between surface normal and view direction) and roughness. The LUT stores two values per texel: a scale and a bias that together parameterize the Fresnel term. Because they're computed under a fixed white environment, they're analytically correct for any real environment when combined with the prefiltered map.

The LUT is computed once, offline. At runtime, it's a single texture lookup - (roughness, NdotV) returns (scale, bias) - and a couple of multiply-adds. No per-pixel integration required.

From Cubemap to Final Pixel

At render time, three lookups reconstruct the full IBL response:

  1. Diffuse - evaluate the SH coefficients with the surface normal (a dot product), or sample a pre-convolved cubemap. This gives the irradiance for the diffuse term.
  2. Specular lighting - sample the prefiltered environment map at the mip level corresponding to the material's roughness. High roughness → higher mip → blurrier reflection.
  3. Specular BRDF - sample the 2D LUT at (NdotV, roughness) to retrieve the scale and bias.


Practical Takeaway

The mip levels of a prefiltered environment map correspond directly to roughness. A roughness of 0 samples the sharpest mip; 1.0 samples the most blurred. That means roughness map quality shows up directly in the IBL response; banding or precision issues in the texture will produce visible artifacts in the reflection.

IBL also assumes PBR-compliant materials. The split-sum derivation is built on the same energy conservation and Fresnel assumptions that PBR materials follow, so the two are designed to work together, and a non-compliant material will produce incorrect results even with a perfectly captured environment.

Static IBL does have one hard constraint: it can't support a dynamic time-of-day cycle. Studios that need that reach for procedural atmospheric systems instead, giving artists direct control over how the sky and lighting evolve over time.

The shader combines them: diffuse albedo × irradiance, added to (BRDF scale × specular color + BRDF bias) × prefiltered radiance. Each of those inputs was precomputed. What the shader actually executes is three texture fetches and a few arithmetic operations - not a hemisphere integral.

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

Day 21 - 🔬 Deep dive - PBR Compliant Work

General / 29 May 2026

Day 13 told you what broken PBR looks like. This post explains why the rules exist in the first place.


PBR-Compliant vs PBR-Using

When authoring shaders and materials, a PBR ruleset ensures that whatever goes into the shader is read out correctly by the lighting system. But the system, the BRDF shading model, also needs to be physically based. Otherwise whatever energy a material carries could be assigned or read out incorrectly, even with the right shader in place.

This distinction is crucial: having a PBR shader doesn’t guarantee physically accurate results. The artwork it processes must adhere to the same rules. A metalness value of 0.5 applied to a physically based BRDF still produces an impossible result, the shader can’t rectify the texture’s error. This is the framework for everything that follows. The sections below delve into the implications, beginning with the physics that necessitates these rules.

Metallic range from 0 to 1.


Energy Conservation

The unifying principle behind all PBR rules: a surface cannot reflect more light than it receives. Diffuse and specular are complementary, as one goes up, the other comes down. This constraint is what makes PBR self-consistent and is the reason the albedo range, metalness binary, and F0 values are what they are.

Whenever a material receives light and scatters it, energy is lost. A bright material like snow scatters more light than a dark material like charcoal, not further, but more of it. This doesn't just affect the material itself; it propagates through the scene via GI light bounces, which is why a non-conserving material can throw off the entire lighting environment around it.


F0 - Reflectance at Normal Incidence

A surface is the interface between the surrounding medium (typically air, with a refractive index of approximately 1.0) and the object's substance. When light hits that interface, the Fresnel equations describe how it splits: some reflects, the rest refracts and enters the material. The proportion that reflects is the Fresnel reflectance F, and it varies with the angle of incidence.

At normal incidence (light arriving perpendicular to the surface at θ = 0°) reflectance reaches its minimum. This baseline value is F0, and it's a fixed property of the material. What makes it useful is how predictable it is: almost every dielectric lands near 0.04 (4%), with a realistic range of roughly 2–6%. Water, plastic, skin, wood, glass: visually very different materials that all sit in roughly the same place. Their differences come from their diffuse response, not their specular.

As the angle increases toward grazing (θ → 90°), F climbs toward 1.0 for all materials and all frequencies. That's the physics behind the edge brightening visible on almost every surface; at grazing angles, everything becomes a mirror.

Metals are the exception. Their F0 is high (typically 0.5 to 1.0) and tinted across the visible spectrum. A gold surface reflects red and green more than blue, giving its reflections their characteristic color. For metals, F0 is the specular color, which is why a PBR workflow stores that color in the albedo map for metallic materials.

This is the physics behind the metalness binary rule. A value of 0.5 describes no real substance; real materials are either dielectrics (F0 ≈ 0.04, uncolored) or metals (F0 high, tinted). The gap between those two ranges is where physically impossible materials live.

The Cook-Torrance BRDF

The Cook-Torrance model splits the surface response into diffuse and specular. The diffuse term (typically a Lambertian or Burley approximation) handles light that enters the material, scatters internally, and exits in a random direction. The specular term handles light that reflects at the surface without entering, and this is where the physical complexity lives.

The specular lobe is the product of three terms.


The Normal Distribution Function (GGX is the standard implementation) describes the statistical distribution of microfacet orientations: microscopic surface irregularities that each reflect light like tiny mirrors. Roughness is the parameter feeding this distribution. A low roughness concentrates the NDF into a tight peak, producing a sharp highlight. A high roughness spreads it wide. This is why a flat grey roughness map reads as artificial: it collapses the NDF to a single value across the entire surface, something no real material does.

The Geometry/Masking term (Schlick's approximation is common) accounts for microfacets that shadow or obscure each other at grazing angles. Without it, the specular response would be unrealistically bright when a surface is viewed or lit obliquely.

The Fresnel term ties directly to F0. Reflectance isn't constant; it scales from F0 at normal incidence up toward 1.0 at grazing angles. The Fresnel term drives this within the shader, so a surface automatically brightens at glancing angles regardless of material type. At perpendicular incidence you see F0; at grazing you see full reflectance. This is what makes PBR materials behave correctly across changing view angles without manual adjustment.

Together, the three terms produce a specular lobe that is energy-conserving by construction: it distributes incoming light, it doesn't create any.


In-Engine Validation

The albedo range and the light test are covered in Day 13 - ⚡️ Quick - How to Spot Broken Materials, this section focuses on what those checks miss, and where the BRDF debug views pick up the slack.

Having ways to do custom Metal or Roughness override debug view can help isolate the specular response and expose metalness values that aren't binary. What a metalness override at 0.5 actually looks like vs 0 or 1, the wrong specular tint is readable immediately.

Roughness override: how a flat grey roughness map collapses the NDF to a single value and makes that obvious. Why these views catch compliance breaks that the albedo overlay doesn't see, you can have a correct albedo and still have a broken specular response. Also: the difference between checking under a neutral lighting vs a directional light for catching roughness issues specifically.


Practical Takeaway

The F0 values are the most practically useful thing to take from this post. Every dielectric sits near 0.04, you don't need to guess or measure. If you're authoring plastic, stone, or skin, the specular sits around 4% and the albedo carries the diffuse color. For metals, F0 is the specular color, so the characteristic tint goes into the albedo map and metalness goes to 1.0: not 0.9, not 0.5, not somewhere in between.

Violating energy conservation has consequences that extend beyond the material itself. A surface that reflects more light than it receives injects extra energy into every GI bounce in the scene, brightening indirect light in ways that are hard to track down. The error doesn't stay local.

The rules in PBR aren't arbitrary restrictions. They're the conditions under which the lighting math, the BRDF, the IBL precomputation, the tone mapping downstream, all produce predictable results. Stay within the ranges and the system behaves correctly. Step outside them and the shader can't correct for it.

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

Day 20 - 💬 Take - The Lighting Skill Gap in Technical Art

General / 28 May 2026

Technical artists tend to think about lighting as a performance problem. It's also a storytelling tool, and that half gets overlooked.

The title might be a slight overstatement. I've only spent brief stretches of my career fully invested in doing lighting. But those periods taught me a lot about how lighting affects everything downstream, post-processing, bloom, exposure, color grading. I also used photography as a stepping stone: understanding how light controls mood and tone, and how you can use it deliberately to direct the eye or reinforce a narrative.

A well-lit scene leads attention. A poorly lit one lets it wander.

source: Resident Evil 7

The Technical Artist's Default

Most technical artists approach lighting primarily as a performance concern. How many dynamic lights are in range? Can we bake this? Are the shadow maps eating the budget? That instinct isn't wrong, lighting can be extremely expensive, and keeping it under control matters.

But the instinct can tip too far. I've seen lighting stripped back to the point where it sacrifices the intended look entirely. There's always a balance to strike between performance and artistic intent, and defaulting too hard to performance loses the plot.

The Student Blind Spot

I see a different version of the same gap with students. They'll spend weeks on a material or a model, getting every detail right, and then present it flat-lit in a gray viewport. The work can't speak for itself in that context.

Working on an asset means wanting to present it as well as possible. Knowing where to add or pull light to emphasize the storytelling matters. In a game environment, not every asset can be a hero prop, and lighting, just as in a film production, can hide a lack of detail where it doesn't matter, and draw attention to where it does.

A simple example: if you're building a horror environment, you don't fill the darkest corners with dense detail. With no light reaching those areas, the player won't see it. Understanding that is set dressing; it's also lighting.

A Note on Deferred vs Forward

Lighting in deferred rendering is generally cheaper per light than in forward rendering, though both have trade-offs.

In forward rendering, each light evaluates its contribution per-fragment, for every piece of geometry within its range, so the cost scales with the number of lights multiplied by the geometry they touch. In deferred rendering, geometry is rendered once to a G-buffer (storing normals, albedo, roughness, depth, and so on), and then lighting is evaluated in screen-space per pixel against the G-buffer data. Lights only operate on what's visible on screen, not on geometry, which makes adding more lights significantly cheaper.

That said, neither system is free. Not every light should cast dynamic shadows, shadow maps have resolution budgets and runtime cost, and using them indiscriminately is how a scene's frame budget disappears. Knowing which lights earn a dynamic shadow and which don't is part of the skill.

Source: learnopengl.com

The Broader Point

Having a basic understanding of how lighting works, how it interacts with surfaces, how it controls mood, and what it costs, is useful regardless of your specific discipline. Whether you're a material artist trying to present your work convincingly, or a technical artist optimizing a pipeline, lighting is part of the picture.

It's one of those areas where a little knowledge goes a long way.

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

Day 19 - ⚡️ Quick - AO and Bent Normals

General / 27 May 2026

Ambient occlusion tells you how much light is blocked. Bent normals tell you which direction it comes from. One number vs one direction, and that difference matters more than it sounds. Again, this started out as a short format but ended up going longer than expected.

What AO Does

Ambient occlusion is a scalar value, a single number per point on a surface that describes how much of the surrounding hemisphere is blocked by nearby geometry. A value of 1 means fully unoccluded, fully exposed to incoming light from every direction. A value of 0 means fully occluded, buried inside geometry with nothing reaching it.

In practice AO is used to darken areas where light has less access, cavities, corners, the underside of a ledge, the gap between two surfaces pressing together. It approximates the soft shadowing that fills those areas in reality, and it does so cheaply because it is baked and static, which also means it can't update for animated or dynamic geometry.

The limitation is that it's directionless. AO darkens a surface based on how occluded it is, but it has no information about where the unblocked light is actually coming from. When combined with IBL, where the incoming radiance varies across the hemisphere - this becomes a problem.

Source: scahilldesign.co.uk


The Gap AO Leaves

When you sample a prefiltered environment map for diffuse lighting, you're integrating incoming radiance across the hemisphere above the surface. AO scales the result down in occluded areas, but it scales it uniformly, as if every direction contributed equally.

In reality, the sky above a surface is bright and the ground below it is dark. A surface sitting in a corner is mostly occluded from above, so it should receive less skylight, but it might still have a clear view toward a bright part of the environment. Scalar AO can't capture this. It just darkens without knowing what it's blocking.

Examples of different real-time AO implementations in Unity.


What Bent Normals Add

A bent normal stores the average unoccluded direction at a given point, the mean direction the surface can actually receive light from, given its local geometry. Instead of a scalar, it's a vector.

When sampling an environment map for indirect lighting, you use the bent normal to look up the right region of the map rather than the geometric normal. The result is that occluded surfaces sample from the part of the environment actually visible to them, not an incorrect average. A surface in a corner looking up at the sky samples the sky. A surface buried in a crevice samples the dimmer environment around it.

The improvement is most visible in complex indoor environments and on characters, where AO-only solutions tend to over-darken flat areas and under-darken the right spots.

Specular Occlusion

On every project I've worked on, specular occlusion has been worth applying. The problem shows up in deep cavities: the reflected view ray doesn't know whether the surrounding geometry is occluded or not, it just looks up a texel from the environment cubemap and reflects it toward the viewer. In heavily occluded areas, this produces light leaks. The surface looks like it's receiving specular light from directions the geometry would never actually allow.

The fix is to use the AO map to attenuate the specular result, multiplying the deepest range of the occlusion map against the specular values to dim or suppress reflections where they're least plausible. You don't need to apply this uniformly; a remapped range that only affects the heavily occluded end keeps the effect targeted. Ideally this is done as a post-process pass rather than per-material, so you pay the cost once rather than per shader.

Bent Normal

Specular Occlusion

More info regarding Specular Occlusion: https://dev.epicgames.com/documentation/unreal-engine/bent-normal-maps-in-unreal-engine?lang=en-US

Practical Takeaway

Bent normals are a baked asset, generated in the same pass as AO. The cost at runtime is minimal, you're replacing one texture lookup (geometric normal) with another (bent normal). In Unreal Engine, bent normals plug directly into the material and are used automatically for indirect lighting when present.

If you're already baking AO, baking bent normals alongside it costs almost nothing and noticeably improves how surfaces read under Image Based Lighting, particularly in areas with strong directional variation in the environment.

I did not discuss the other implementations that are available regarding realtime ambient occlusion ways of generating this information.

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

Day 18 - 🔬 Deep Dive - Linear Luminance

General / 26 May 2026

Light doesn't behave the way your eyes tell you it does. Understanding the difference is what separates renders that look lit from renders that look real.

What Is Luminance

Luminance is the amount of light emitted or reflected from a surface per unit area, in a given direction. It is a physical quantity, measured in candelas per square-meter (cd/m²), and it describes what is actually happening with the light, not how a person perceives it.

Brightness is different. Brightness is a perceptual response, how the visual system interprets incoming luminance. Two surfaces with the same luminance can look different in brightness depending on context, surrounding values, and adaptation state.

The distinction matters in rendering because the math works with luminance, but the artist is looking at perceived brightness. Confusing the two is one of the most common reasons lighting setups feel physically off even when the numbers seem right.

How the Eye Perceives Light

The human visual system responds to light logarithmically, not linearly. Doubling the physical luminance of a surface does not look like doubling its brightness. It looks like a modest step at the bright end and a much larger one at the dark end. The eye is far more sensitive to changes in shadow than in highlights.

This is why a candle in a dark room stands out, and the same candle in daylight is simply invisible. It's all about relative light perception. The eye continuously adapts its sensitivity based on the surrounding luminance range, a process called adaptation, which means absolute luminance values matter far less than relative ones.

It also explains why HDR capture and tone mapping exist. No display can reproduce the full luminance range a scene contains, so the pipeline has to compress it in a way that approximates how the eye would have adapted to that scene in reality.

Linear Light vs Perceptual Brightness

Working in linear light means the renderer operates on physically correct values. Energy accumulates correctly - two lights of equal intensity produce twice the luminance at a surface. Falloff follows the inverse square law. Materials reflect the proportionally correct amount of incoming light.

Working in perceptual space feels more intuitive because it matches what the eye sees, but it breaks the physics. Add two perceptual brightness values together and the result is wrong. Apply a falloff curve and the math no longer describes reality.

This is the foundation behind the linear workflow and the Gamma Correction discussed in Day 16. The renderer works in linear to keep the physics intact; the gamma encode at the end maps the result back to something the display and the eye can interpret correctly.


Luminance Ranges in Real Scenes

To understand why tone mapping is necessary, it helps to have a sense of the actual luminance range involved:

The full range the adapted eye can handle spans roughly 10 orders of magnitude. No display or file format captures this. A typical SDR monitor tops out around 100-200 cd/m²; a high-end HDR display might reach 1,000–2,000 cd/m². The sun disk is still a billion times brighter.

Tone mapping is therefore a compression problem, not a correction problem. The goal is not to reproduce the scene accurately - that is physically impossible - but to compress the luminance range into something a display can show while preserving the perceptual relationships the eye would have experienced in the original scene.

Conclusion

Knowing real-world luminance ranges gives you a reference point for setting exposure and tone mapping parameters. Instead of eyeballing until it looks right, you can ask: does my sky sit in a plausible range relative to my shadowed surfaces? Does my interior feel correct relative to the window behind it?

The logarithmic perception point is also practically useful: small numerical changes in dark areas will read as larger perceptual shifts than the same change in bright areas. If your shadow detail is disappearing or your darks feel crushed, the issue is often less about the values themselves and more about where they land relative to the eye's adapted range.

With all the things I am learning regarding luminance, I would love to delve further into tone mapping and measuring luminance myself with a lightmeter and experiment with my own library. I still have a lot to learn about Exposure and how it translates into the digital domain.

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