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.

Day 17 - 💬 Take - Why Real-Time Lighting Is Harder Than Offline

Article / 22 May 2026

Offline rendering can simply throw more computation at a problem. Real-time can't. That constraint changes everything about how you approach lighting.

This is a take that tends to land awkwardly when I mention it to someone else: real-time lighting is harder than offline rendering.

Offline rendering is what the film industry uses. It produces photorealistic results that real-time can't quite match — though the gap is getting closer, with hardware ray tracing and more material standardization becoming standard in games. Offline path tracers simulate millions of rays per pixel across multiple bounces, catching every caustic, every subtle interreflection, every gradient in a shadow, and penumbras across assets of wildly different scales. How could anything harder than that exist?

The answer is that offline rendering solves a physics problem — over hours, if needed. Real-time rendering solves the same physics problem inside a budget of approximately 16 milliseconds per frame at 60fps. Those are not the same challenge.

Offline Gets to Be Honest

In an offline renderer, the path tracer does something elegant: it simulates light transport as it actually occurs in real life. Rays bounce, scatter, and accumulate energy; the solution is noisy early and accurate late, and you simply wait for it to converge. Add more samples and the result improves. Add more time and it improves further. The algorithm doesn't know or care how long it runs.

This is a fundamentally different problem from real-time. The physics doesn't get simplified or approximated — it gets sampled more or less densely. Errors are statistical noise that averages out, not structural approximations that introduce systematic bias. Offline rendering can portray materials accurately: ground-truth subsurface scattering, physically correct glass refraction and diffraction, volumetric light transport — all tractable given enough time.

Real-time rendering has no such luxury. The frame must finish in 16ms at 60fps. Every technique used in that budget is an approximation of something the path tracer would compute correctly given enough time.

Real-Time Is an Exercise in Controlled Deception

The techniques that make real-time lighting work are, in a real sense, tricks. They are tricks built on deep understanding of the physics, but they are tricks.

IBL pre-filters incoming radiance from all directions into an environment cubemap and pre-computes the BRDF response into a lookup table, then reconstructs the full lighting integral with just two texture samples at runtime. This is the split-sum approximation, introduced by Brian Karis at Epic. Path tracing would evaluate that integral directly per frame. The precomputed version is fast and close — but it's static, it doesn't respond to scene changes, and the split-sum approximation introduces visible error at extreme roughness values.

Screen-space ambient occlusion samples a sphere or hemisphere of depth buffer values — typically at half resolution — and uses them to estimate occlusion. It's fast and visually convincing, but it misses geometry outside the screen, produces halos around silhouettes, and has no notion of which parts of the environment the occluded surface actually sees. Bent normals help, but a path tracer would simply trace the rays.

Death Stranding SSAO. Source: Behind the Pretty Frames: Death Stranding

Shadow maps project geometry from the light's perspective and check depth values. They work, and they're everywhere. They also alias at range, have resolution budgets, can't easily handle area lights, and produce contact shadows only where the resolution allows it. Path-traced shadows are free once the rays are in flight.

Reflection captures bake a static cubemap at a point in the scene and use it for specular reflections. It is, in effect, a photograph taken during level build that gets composited onto every surface in the vicinity. Screen-space reflections layer on top and add dynamism, but they miss geometry off-screen and fall back to the static capture at the edges. A path tracer would just trace the reflection ray to wherever it terminates.

The Skill Requirement Is Different

Offline lighting lets you be relatively naive about the physics and still get correct results. Point a camera, place some lights, let the renderer do the integration. The errors are convergence errors, not systemic ones.

Real-time requires you to understand the approximations well enough to stay inside the range where they hold up. You need to know that IBL breaks down when the environment is high contrast and the surface is a rough conductor. You need to know where screen-space reflections will fail and what the fallback looks like. You need to understand why a light probe placed in the wrong position introduces color contamination across an entire room.

Getting real-time lighting to look correct isn't about understanding the physics. It's about understanding a stack of approximations layered on top of the physics, knowing where each one breaks, and composing them in a way that hides the seams.

That's a harder skill to develop than understanding the physics alone. And it's one that doesn't get enough credit, in my opinion.

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

Day 16 - ⚡️ Quick - Gamma Correction

Article / 21 May 2026

Every image you've ever created on a computer was made in the wrong color space. Not wrong enough to look broken - but wrong enough that the physics underneath your lighting or shading math doesn't add up. That's gamma. This is a somewhat high-level overview of why it exists and where it tends to break.

Why Monitors Lie About Brightness

Monitors don't display light linearly - they apply a power curve before output (~2.2 for monitors, ~2.4 for TVs). This started as a physical property of CRT displays: doubling the input voltage didn't double the brightness. That non-linearity happened to closely match how human eyes perceive brightness, so the convention stuck long after CRTs disappeared. In 1996, HP and Microsoft formalized it into the sRGB standard - which is why almost every image file and display today still operates in this space.

John Hable's Linear-Space Lighting post on Filmic Worlds (originally a GDC talk) illustrates this with a simple test: the perceptual midpoint between 0 and 255 is not 128 - it's 187. A value of 128 looks much darker than halfway. That's the gamma curve in action. It also explains why every photo on your hard drive is already gamma-encoded: cameras apply the inverse curve at capture (pow(x, 1/2.2)) so the stored image is bright and pastel-ish, and the monitor's own gamma curve brings it back to correct at display time.

The result for rendering: if your renderer works in linear light and outputs without correction, the image looks washed out and overexposed. Gamma correction applies the inverse curve before the signal hits the display, so what you see matches what the renderer calculated.


187 is the perceptual midpoint between 0 and 255 - not 128. Via John Hable / Filmic Worlds.

Left: gamma-space lighting - soft, incorrect falloff, visible hue shifting in specular. Right: linear-space lighting - harsh falloff that matches physical reality. Via John Hable / Filmic Worlds.

Why Linear Space Matters for PBR

PBR math assumes linear light values - doubling the intensity should double the result. If your textures are in sRGB (gamma-encoded) and you feed them into the shader without converting, the math breaks. The albedo looks too bright, lighting doesn't accumulate correctly, and specular highlights shift hue. Hable specifically notes the white specular highlight drifting from white to yellow to green and back in gamma space - a diagnostic signal that the pipeline is operating in the wrong space. Everything needs to be calculated in linear; the final gamma conversion happens at the very end, based on the target display.

Without a proper linear pipeline, the gamma errors in input and output partially cancel each other out - which is why gamma-incorrect projects can still look acceptable. It's two wrongs making a right, and it holds up until the lighting gets complex enough to expose the cracks.

The volleyball comparison in Hable's post makes this concrete: linear-space lighting produces a harsh falloff that matches the reference photo; gamma-space produces a soft, incorrect falloff that looks plausible but doesn't match reality.


Top: linear-space lighting. Bottom: gamma-space. The harsh falloff of the linear version matches the real photo. Via John Hable / Filmic Worlds.

The same logic applies to exposure and tonemapping - both depend on linear values being correct before the final encode.

High Dynamic Range

It gets more complex with HDR TVs and monitors. They don't follow a simple gamma curve - HDR uses standardized transfer functions instead: PQ (Perceptual Quantizer) for most HDR10 content, and HLG (Hybrid Log-Gamma) for broadcast. These are designed to map a much wider luminance range to the display, not just correct for CRT legacy. A renderer targeting HDR output needs to account for this at the end of the pipeline rather than applying a standard gamma 2.2 encode. In practice this means checking your engine's HDR output settings and testing on both SDR and HDR displays - the same content can look noticeably different between the two.

Where It Breaks

Most gamma issues don't announce themselves - they show up as subtle wrongness that's hard to trace back to a source. These are the most common places the chain breaks.

  • Textures flagged incorrectly - albedo should be sRGB, roughness/metalness/normals should be linear. Marking a normal map as sRGB is a fast way to get subtly wrong shading that's hard to diagnose. Most engines handle the conversion automatically if the texture is flagged correctly, but make sure your shader system is actually converting on sample - otherwise the flag is meaningless.
  • Texture compression - enabling gamma space on albedo compression is correct, since BC compression algorithms prioritize blocks based on perceptual importance. The same approach doesn't apply to normal maps, which need to be treated as linear data.
  • Compositing outside the renderer - taking a linear render into Photoshop without converting first means every layer blend operates in the wrong space.
  • Manual color values in shaders - typing 0.5 into a linear input is not the same as 128 sRGB. A common source of "why does this look different in-engine?" confusion.
  • Light attenuation - before linear workflows were standard, artists used linear falloff (1/distance) instead of the physically correct quadratic (1/distance²) because quadratic looked too harsh on screen. With proper gamma correction in place, quadratic suddenly gives correct results.
  • Exposure and post-processing - exposure, bloom, color grading, and tonemapping all need to operate on linear values to produce correct results. Exposure applied in gamma space lifts and clips differently than it should - highlights compress too early and shadows respond unevenly. Bloom is a clear example: in gamma space, bright areas are already compressed, so the bloom spreads less than it physically should. In linear space, bright pixels carry their actual intensity, and the effect behaves correctly. The right order is always: render linear → post-process linear → tonemap → gamma encode → output.

Get these right and gamma stops being a source of mystery bugs.

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

Day 15 - 📖 Learning - Week 2 Reflection

Article / 20 May 2026

The second week moved into materials territory. Some posts ran deeper than planned - here's what I took from it.

What I Covered

Week 2 spanned material creation, pixel coverage, the depth prepass, heat oxidation, photo reference, and building a material library. The range was wider than Week 1, which kept things interesting but also made it harder to keep posts tight.

A few of these were genuine refreshers - concepts I knew well enough to apply, but hadn't articulated clearly before. Writing forces a level of precision that just doing the work doesn't. Some topics also opened up into more scientific territory than expected - the heat oxidation post and the material library structure both turned into deeper explorations than originally scoped.

What Ran Long

Several posts ended up more technically in-depth than I anticipated. The heat oxidation post is the clearest example - what started as a quick breakdown turned into a proper research pass with a reference table and color values. That kind of scope creep is hard to predict upfront.

The payoff was real, though. Posts with visual examples - the heat discoloration gradient in particular - landed better than posts that were text-heavy.

The depth prepass also ran longer than expected - getting the diagram right to clearly show the pipeline order took more iteration than the writing itself.

What Writing at Pace Actually Feels Like

At some point it becomes easier, there's less overthinking, more just doing it. The more complex the topics get, the harder it is to do them justice without visual examples. Momentum is still important - one missed day and the whole system starts to slip. No pressure.

What's Next

Block 2 moves into rendering and lighting. After two weeks of pipelines and materials, shifting into how light interacts with the surfaces I've been building feels like a natural next step, the two are inseparable if you want convincing results.

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

Day 14 - ⚡️ Quick - Using Gloss Meters for Surface Measurement

Article / 19 May 2026

Real-world measurement tools can anchor your PBR materials to physical reality - here's how a gloss meter fits into that workflow.

Why Be Scientific About It

The more grounded a material is in real-world values, the more convincing it tends to read - even when it's fictional. Even alien-like materials in games follow the logic of how we perceive and author the world, just with modifications. An alien weapon material could be carbon fiber with a hint of thin-film interference applied to it. The starting point is still based on real-life. Everything in art is somehow derived from real-life examples, and measurement tools are one way to make this based on ground-truth rather than approximating.

What a Gloss Meter Actually Measures

Gloss meters are common in automotive and manufacturing - used to check whether surface imperfections are causing inconsistency in a panel's gloss value. For material artists, the same tool works as a reliable scientific indicator for surface values that would otherwise be estimated by eye.

A gloss meter measures surface reflectivity in gloss units (GU) at a defined angle - 60° is the standard for most surfaces. As a rough reference:

  • Matte surfaces: < 10 GU
  • Semi-gloss: 10–70 GU
  • High gloss: > 70 GU


Typically, you'd grab 3-5 measurements at different positions and take the average. Use that average as the baseline for the glossiness value.

The conversion to roughness is not linear. A common approximation is:

roughness = 1 − √(gloss / 100)

That said, the exact curve depends on your engine's shading model and how it interprets the roughness/gloss input. Normalize to 0–1 first, then apply any engine-specific remapping at the end.


Material Limitations

Not all surfaces are easy to capture reliably. A few constraints worth knowing before you start:

  • Curved surfaces give inconsistent readings; the meter needs flat contact with the surface to work correctly
  • Heavily textured or rough surfaces scatter light too much for a stable GU value - the reading will vary across the same sample
  • Transparent materials such as glass won't give useful results; the meter reads the surface reflection, not the bulk material
  • Soft or deformable materials (fabric, foam, leather) are difficult to press against consistently without distorting the surface
  • Very dark surfaces near 0 GU are hard to differentiate from each other - small measurement errors become proportionally significant at the low end

For those cases, visual reference photography and cross-polarized capture are a better approach than trying to force a GU reading.

My Setup

Have I used a gloss meter in my work? Not yet in a professional capacity. I have been planning to do it for a while, but not all projects allow time for experimentation. I did buy one and have been measuring different values, but I haven't been as organized or consistent as I'd like in capturing the actual material source alongside the measurements.

Ideally I'd pair each measurement with a cross-polarized photo - this eliminates the specular highlight and captures the true diffuse/albedo of the surface, which is otherwise hard to isolate with a standard camera. Cross-polarized setups require two polarizing filters (one on the light, one on the lens, oriented 90° to each other) and are effective and are not portable. An albedo capture device would be even more accurate, but those are expensive, so that remains a future goal.

How to Build a Reference Library

Pick a wide variety of materials covering the full range from very dull to very shiny. Organize the capture consistently:

  1. Measure and log the GU value for each sample
  2. Photograph the surface - ideally cross-polarized, otherwise a controlled flat-lit shot
  3. Store both in a local database alongside material type, finish, and any relevant context
  4. Convert to roughness at the end - keep raw GU values in your database, normalize to 0–1, and apply the engine-specific conversion only when you need to use the data

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

Day 13 - ⚡️ Quick - How to Spot Broken Materials

Article / 18 May 2026

A quick checklist for reading a render and catching the most common material problems before they make it downstream.

Most material issues are visible the moment you know what to look for. As content locks down on a project, automated visual comparison systems can help catch regressions in shaders and textures before they reach QA. But automation catches drift - it doesn't replace the eye. Being able to read a render and identify the problem yourself is a skill worth developing early.

The checklist below splits issues into two tiers: red flags are definite breaks that shouldn't ship, orange flags are warning signs worth a second look.

Red Flags

These are definite breaks - a material with any of these issues should not ship.

1. Albedo out of PBR range
Non-metals should fall between 30-240 sRGB. Values below 30 are physically impossible - charcoal, one of the darkest materials found in nature, sits right at 30 sRGB. Values above 240 blow out under any real lighting; snow, one of the brightest, lands at 237 sRGB. Metals sit between 180-255 sRGB.

One thing worth deciding early: whether to work in sRGB or linear values. Pick one and stay consistent - mixing the two is a common source of out-of-range errors that are hard to trace later.

2. Metalness on non-metals
Metalness is binary: 0 for dielectric (plastic, wood, fabric, skin), 1 for metal. Grayscale values between 0.2-0.8 produce physically impossible semiconductor looks. Plastic with metalness at 0.5 will have the wrong specular color and incorrect reflectance behavior entirely.

The one exception worth noting: when blending a metal with oxidization - rust streaks on steel, for example. Rust is technically a dielectric, but forcing a hard transition between metalness values tends to look wrong. A soft blend works better visually, even if it's not physically strict.

side-by-side: of 50% gray at metalness 0 vs metalness 0.5 vs metalness 1.0 - wrong specular tint is immediately readable.

3. Inverted or broken normals
Shading that reads flat, or surfaces that appear to be lit from the wrong direction. Usually caused by a flipped green channel (OpenGL vs DirectX convention mismatch) or incorrect tangent space. Easy to spot: rotate the light and see if highlights move in the wrong direction.

example of inverted normals in Unreal Engine. source: Reddit/Unreal

Orange Flags

These are warning signs - worth reviewing, may be intentional, but usually aren't.

4. Uniform roughness
A single roughness value across the entire surface reads as artificial. Real surfaces accumulate wear, polish, and contamination unevenly. If the roughness map is a flat grey, the material will look like a render rather than a surface.

flat roughness vs varied roughness on the same mesh under the same light.

5. Tiling artifacts
Visible repeat seams or cross-pattern under any lighting angle. Most obvious on large surfaces - floors, walls, terrain. A rotating directional light will catch seams that are invisible under flat HDRI.

Source: iquilezles.org - texture repetition

6. Missing micro-variation
No surface noise in roughness or normals. The material reads too clean - no fingerprints, no micro-scratches, no atmospheric deposit. Unless it's intentional (polished mirror, fresh paint), it will look like an untextured mesh.

The Light Test

Most of these issues are invisible under flat or neutral HDRI. A two-step light test exposes them fast:

  1. Rotate a single directional light 360° - broken normals, wrong specularity, and tiling seams all reveal themselves at specific angles that flat HDRI hides.
  2. Switch between overcast HDRI and direct sun - albedo out of range will blow out under direct sun; uniform roughness becomes obvious when you have a sharp specular highlight to work with.
  3. Add debug views in your texturing software - flag values below 30 sRGB in blue and above 240 sRGB in red. Out-of-range albedo that's invisible in a normal viewport becomes immediately obvious with a validation overlay.

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