7.3 Lighting & PBR

Phase 7 · Graphics & Rendering · Study time: 40–70 h

How light and materials are modeled physically (metalness/roughness, Fresnel, image-based lighting) so surfaces look believably real.

Every shader you wrote in the last lesson picked a color and used it directly — a texture sample, a flat tint, maybe a gradient. None of it knew where the light in the scene was coming from. This lesson adds light. You will build a shader that actually reacts to a light source, see exactly why the classic tricks programmers used for decades eventually stopped looking right, and learn the ideas behind PBR (Physically Based Rendering — writing lighting math that follows real physics closely enough that a material looks correct under any lighting, not just the one light you happened to test with).

1. The Three Vectors Every Lighting Shader Needs

Recall from the rendering pipeline lesson: the vertex shader runs once per vertex and the fragment shader runs once per pixel, with the GPU smoothly interpolating (blending) whatever the vertex shader outputs across each triangle's surface. Every lighting formula in this lesson is built from three arrows, all drawn from the same point on the surface being shaded:

Vertex Shader (runs once per vertex) Fragment Shader (runs once per pixel) --------------------------------- ------------------------------------- takes object-space position and --> receives an INTERPOLATED world normal, converts them to world position and world normal for space, passes them down the this exact pixel (the GPU blends pipeline through the v2f struct vertex outputs across the triangle) this is where the lighting math happens: N, L, and V all get computed or used here

To get N and V in world space, every shader below starts from the same small building block. This is worth typing out once, since every shader in this lesson reuses it:


struct appdata
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
};

struct v2f
{
    float4 pos         : SV_POSITION;
    float3 worldNormal : TEXCOORD0;
    float3 worldPos    : TEXCOORD1;
};

v2f vert(appdata v)
{
    v2f o;
    o.pos         = UnityObjectToClipPos(v.vertex);
    o.worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos    = mul(unity_ObjectToWorld, v.vertex).xyz;
    return o;
}

Inside frag(), N comes from normalizing i.worldNormal (normalizing (scaling a vector so its length becomes exactly 1) matters because interpolating two unit-length normals across a triangle can produce a slightly-shorter-than-1 result). V comes from normalize(_WorldSpaceCameraPos - i.worldPos) — the camera's world position minus this pixel's world position, pointing back toward the eye. L, for a single directional light, is simply normalize(_WorldSpaceLightPos0.xyz), a Unity built-in variable that already holds the direction toward the scene's main directional light.

Tip Every shader in this lesson uses Unity's built-in render pipeline (CGPROGRAM, one directional light, no shadows) so the lighting math stays front and center. A production shader would add #pragma multi_compile_fwdbase and a second ForwardAdd pass to handle shadows and extra lights — that plumbing is a separate topic from the math itself, which is what this lesson is about.

2. Faking Light, Take One: Lambertian Diffuse

Think about a matte surface — chalk, a sheet of paper, unpolished wood. Light hitting it scatters roughly evenly in every direction, so from any viewing angle the surface's brightness only depends on one thing: how directly it faces the light. A patch of surface facing straight at the light gets hit by a dense bundle of light rays; the same patch tilted away catches the same rays spread over a wider area, so it looks dimmer. This is called Lambertian reflectance (named after Johann Lambert), and it is the oldest trick for faking light on a surface.

The angle between N and L tells you exactly how "facing" the surface is, and there is a one-line way to get it: the dot product. For two unit-length vectors, dot(N, L) equals the cosine of the angle between them — this is the same dot product from the linear algebra lessons, now doing real work in a shader.

N (surface normal) ^ | | L (direction TO the light) | / | / theta = angle between N and L | / |/ -----+------------------------ surface N.L = cos(theta) theta = 0 deg --> N.L = 1 (light hits straight on -- brightest) theta = 90 deg --> N.L = 0 (light grazes the surface -- dark) theta > 90 deg --> N.L < 0 (light is behind the surface -- clamp to 0)

Worked trace: say N = (0, 1, 0) (straight up) and L = (0, 0.707, 0.707) (the light sits about 45 degrees above the horizon). dot(N, L) = (0)(0) + (1)(0.707) + (0)(0.707) = 0.707, and cos(45°) ≈ 0.707 — matching exactly, as it should. Now say the light is almost level with the surface, L = (0, 0.1, 0.995): dot(N, L) = 0.1, so the surface is barely lit, even though the light is still technically above the horizon.


fixed4 _Color;

fixed4 frag(v2f i) : SV_Target
{
    float3 N = normalize(i.worldNormal);
    float3 L = normalize(_WorldSpaceLightPos0.xyz);

    float NdotL = max(0, dot(N, L));
    fixed3 diffuse = _Color.rgb * _LightColor0.rgb * NdotL;

    return fixed4(diffuse, 1);
}

Expected result: on a sphere, the side facing the light is brightest, brightness fades smoothly toward the "terminator" (the line where the surface turns edge-on to the light), and the far side — which never faces the light at all — is completely black. Rotating the camera around the sphere changes nothing, because this formula never uses V at all; a Lambertian surface looks the same brightness from every viewing angle.

Common mistake Skipping the max(0, ...) clamp. Once a triangle faces away from the light, dot(N, L) goes negative, and an unclamped negative value multiplied into a color can produce strange, flickering dark artifacts instead of a clean black. Always clamp a dot product before using it as a light amount.

3. Faking Shine: Blinn-Phong Specular

Lambertian diffuse alone makes every surface look like chalk — flat and matte. Shiny materials (polished metal, wet surfaces, plastic) also show a bright specular highlight (the blurred, bright reflection of the light source itself). Unlike diffuse light, the highlight's position depends on where the camera is, because you are seeing a rough mirror-image of the light.

The classic (and still very fast) way to fake this is Blinn-Phong shading. Instead of computing the true mirror-reflection direction of L off the surface (which is what the older, more expensive Phong model did), Blinn's trick uses a cheaper stand-in: the half vector H, which sits exactly halfway between L and V. When N points close to H, the surface is near the "sweet spot" where light bounces almost straight from the light into the camera — the highlight.

N ^ L | V \ | / \ | / \ | / \ | / \ | / -----------+------------------ surface point P H = normalize(L + V) -- halfway between L and V N.H is largest exactly when the surface normal points at the halfway point between "toward the light" and "toward the eye" -- that is the shiny highlight.

float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
float3 H = normalize(L + V);

float NdotH = max(0, dot(N, H));
float specular = pow(NdotH, _Shininess);

pow(NdotH, _Shininess) raises a number between 0 and 1 to a power — the higher the exponent, the faster the value collapses toward 0 as N.H drops even slightly below 1. That is exactly the shape a highlight needs: a small, bright spot that fades out quickly around its edges. _Shininess here is a plain float the artist tunes by hand (typically 8 to 256) — a low value spreads the highlight into a wide, soft glow; a high value squeezes it into a tiny, sharp glint. Hold onto that idea; it comes back with a real physical meaning in Section 7.

4. A Complete Diffuse + Specular Shader

Putting Sections 2 and 3 together, plus a flat ambient term (a constant amount of light added everywhere, standing in for all the indirect bounce light a single directional light ignores), gives the classic lighting model almost every game used before PBR became standard:


Shader "Lesson/07_3_BlinnPhong"
{
    Properties
    {
        _Color      ("Diffuse Color",  Color) = (0.8, 0.8, 0.8, 1)
        _SpecColor  ("Specular Color", Color) = (1, 1, 1, 1)
        _Shininess  ("Shininess", Range(8, 256)) = 64
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" }
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            fixed4 _Color;
            fixed4 _SpecColor;
            float  _Shininess;

            struct appdata { float4 vertex:POSITION; float3 normal:NORMAL; };
            struct v2f
            {
                float4 pos         : SV_POSITION;
                float3 worldNormal : TEXCOORD0;
                float3 worldPos    : TEXCOORD1;
            };

            v2f vert(appdata v)
            {
                v2f o;
                o.pos         = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos    = mul(unity_ObjectToWorld, v.vertex).xyz;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float3 N = normalize(i.worldNormal);
                float3 L = normalize(_WorldSpaceLightPos0.xyz);
                float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
                float3 H = normalize(L + V);

                float NdotL = max(0, dot(N, L));
                float NdotH = max(0, dot(N, H));

                fixed3 ambient  = UNITY_LIGHTMODEL_AMBIENT.rgb * _Color.rgb;
                fixed3 diffuse  = _Color.rgb * _LightColor0.rgb * NdotL;
                fixed3 specular = _SpecColor.rgb * _LightColor0.rgb * pow(NdotH, _Shininess);

                return fixed4(ambient + diffuse + specular, 1);
            }
            ENDCG
        }
    }
}

Expected result: a sphere with a smooth Lambert gradient across most of its surface, plus a small, bright, white-ish highlight near where the light reflects toward the camera. Orbiting the camera around the sphere slides the highlight across the surface — it is chasing V — while the diffuse gradient underneath stays fixed, since it only depends on L. This is the look of "plastic" in almost every 2000s-era game.

5. Why Blinn-Phong Breaks Down

Blinn-Phong shipped in thousands of games for good reason — it is cheap and looks reasonable. But it is a hand-tuned illusion, not a model of how light actually behaves, and that catches up with it in a few specific ways:

Every one of these problems has the same root cause: Blinn-Phong is a shape that happens to look plausible, not a model grounded in physics. PBR replaces the "shape that looks plausible" with a formula grounded in three physical ideas — energy conservation, the microfacet model, and Fresnel reflectance — plus a workflow that ties a material's parameters together the way real materials actually behave. The rest of this lesson builds each of those pieces.

6. What "Physically Based" Means: Radiance and Energy Conservation

Radiance is the technical name for "how much light energy is arriving at, or leaving, a point, in a specific direction." You do not need the full physics definition — for this lesson, just read it as "brightness, but precise about which direction it is measured in." A PBR shader's whole job is computing outgoing radiance toward the camera, given incoming radiance from a light.

The function that describes how a material redirects that incoming light is called a BRDF (Bidirectional Reflectance Distribution Function — a recipe for "given light coming in from direction L, what fraction of it leaves toward direction V"). Lambert's N.L and Blinn-Phong's pow(N.H, shininess) are both extremely simple BRDFs. PBR does not throw the idea of a BRDF away — it replaces those two ad hoc formulas with ones built to respect a hard physical rule:

Energy conservation, in one line: light energy reflected out <= light energy that arrived Split between diffuse and specular: (fraction reflected as diffuse) + (fraction reflected as specular) <= 1 Blinn-Phong enforced none of this -- diffuse and specular were two separate, disconnected formulas an artist tuned by eye. A PBR BRDF is built so this budget can never be exceeded, no matter what values go into albedo, metallic, or roughness.

Think of it as a budget: if a point receives 100 units of light and the material's specular response sends 90 of them straight back out, at most 10 units are left over for the diffuse bounce — never both 90 and, say, 80. PBR BRDFs bake this budget into the math itself (you will see exactly how in Sections 8 and 10), so a material can never accidentally look brighter than the light hitting it.

Tip This energy math only comes out correct if colors are stored as linear light values, not the gamma-encoded values a monitor expects for display. In Unity, set Player Settings > Color Space to Linear (rather than Gamma) before doing any serious lighting work — with Gamma space, all of this lesson's math is quietly wrong, even though nothing errors or crashes.

7. The Microfacet Model: Millions of Tiny Mirrors

Here is the idea that PBR specular is built on: zoom in far enough on almost any surface — even one that looks perfectly smooth to the eye — and it is not flat at all. It is covered in millions of microscopic bumps, each one too small to see individually, and each one acting like a tiny, perfect mirror. This is the microfacet model, and each of those tiny mirrors is a microfacet.

A single microfacet only reflects light in one exact mirror direction, same as a real mirror. What looks like a soft, spread-out highlight on a "rough" material is really millions of these tiny perfect mirrors, each tilted slightly differently, each bouncing its own ray in its own direction — and your eye (or a pixel on the screen) is averaging all of those tiny reflections together. Roughness is simply a number describing how much those microfacet directions spread out around the average surface normal N.

Smooth surface (low roughness) -- facets nearly all point the same way: in | | | out | | | | | | | | | _____v___v___v_______________^___^___^_____ --------------------------------------------- almost flat -- facets barely tilted Result: reflected rays stay bunched together --> small, bright, sharp highlight. Looks like polished chrome or glass. Rough surface (high roughness) -- facets point every which way: in | | | out | | | | | | \ | / \ | _____v___v___v______________^_^____^____^_____ _^_v___^__v_^___v_^_v___^__v_^____v_^_v____ jagged -- facets tilted randomly Result: reflected rays scatter across many directions --> big, dim, soft highlight. Looks like brushed metal or matte plastic.

This single idea explains something Blinn-Phong's exponent could only fake by trial and error: roughness has a direct, physical meaning — it literally is "how scattered are this surface's tiny mirrors" — and one roughness value works correctly no matter which direction the light comes from or where the camera stands, because it describes the surface itself, not a particular lighting setup. Section 10 shows the formula PBR uses to turn "how scattered" into an actual number for the highlight's brightness and shape — a direct replacement for pow(NdotH, _Shininess), driven by something measurable instead of an artist's guess.

8. The Metallic/Roughness Workflow

Almost every modern game engine (Unity's Standard Shader, URP's Lit shader, Unreal's default materials) authors materials using four textures, called the metallic/roughness workflow:

Albedo map Metallic map Roughness map Normal map (base color) (0 = non-metal, (0 = mirror smooth, (per-pixel 1 = metal) 1 = fully rough) surface tilt) +----------+ +----------+ +----------+ +----------+ | colors | | usually | | dark = | | purple- | | (RGB | | near-0 | | smooth, | | ish RGB, | | texture)| | or near-1| | white = | | encodes | | | | per pixel| | rough | | X,Y,Z | +----------+ +----------+ +----------+ +----------+ \ | | / \ | | / \_________________|____________________|__________________/ | v fragment shader combines all four, per pixel, into one lit, physically plausible color

Why does metal need special treatment? A metal's surface electrons absorb incoming light and immediately re-radiate it right at the surface — none of it enters the material and scatters back out the way it does in a dielectric. That means metals have no diffuse term at all: 100% of the light a metal reflects is specular, and that specular reflection is tinted by the metal's own color (gold looks yellow, copper looks orange). Since metals have no diffuse color to store, engines reuse the albedo slot to instead hold the specular tint when _Metallic is 1. A dielectric's specular reflection, by contrast, is almost always a dim, colorless white, regardless of the material's own color — that's why a red plastic ball's highlight is white, not red.


sampler2D _AlbedoMap;
sampler2D _MetallicMap;
sampler2D _RoughnessMap;
sampler2D _BumpMap;

fixed4 frag(v2f i) : SV_Target
{
    fixed3 albedo    = tex2D(_AlbedoMap, i.uv).rgb;
    float  metallic  = tex2D(_MetallicMap, i.uv).r;
    float  roughness = tex2D(_RoughnessMap, i.uv).r;
    float3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));

    // Combine the per-pixel normal map with the mesh's own
    // tangent-space basis (built the same way your earlier
    // normal-mapping shader did) to get the final world normal.
    float3 N = normalize(mul(tangentNormal, i.tangentToWorld));

    // albedo, metallic, roughness, and N now feed every formula
    // in the rest of this lesson.
    return fixed4(albedo, 1);
}
Tip Sampling four separate textures per pixel is wasteful bandwidth-wise, so real projects usually pack metallic, roughness, and an ambient-occlusion mask into the R, G, and B channels of a single texture (Unreal calls this an "ORM" map: Occlusion/Roughness/Metallic). One texture fetch instead of three, same information.

9. Fresnel: Everything Gets Shinier at Grazing Angles

Look straight down into a still lake and you mostly see through the water to the bottom. Look across the same lake, almost level with the surface, and it turns into a near-perfect mirror reflecting the sky — even though the water itself never changed. This is the Fresnel effect (named after Augustin-Jean Fresnel): every surface, no matter how dull it looks head-on, becomes dramatically more reflective at a shallow, grazing viewing angle.

Straight-on view (near-normal incidence): Grazing view (looking along the surface): V (looking straight down) V (looking almost sideways) | ----> v ~~~~~~~~~~~~~ water surface ~~~~~~~~~~~ ~~~~~~~~~~~~~ water surface ~~~~~~~~~~~ mostly transparent -- you see the turns mirror-like -- you see the sky lake bottom through it (low reflectance, reflected instead (reflectance climbs F0 is around 0.02 for water) up toward 1.0 near the grazing angle)

The reflectance when looking straight at a surface (angle = 0) is called F0 (pronounced "F-zero," short for base reflectance). Dielectrics all cluster around a low F0, roughly 0.02–0.05, regardless of their color — water, glass, plastic, and skin are all close to this range. Metals, on the other hand, have a high F0 equal to their own albedo color, which is exactly the "specular tint" idea from Section 8. Every material's actual reflectance climbs from its F0 up toward nearly 1.0 (100% reflective, like a perfect mirror) as the viewing angle approaches grazing.

The industry-standard way to compute this in a shader is Schlick's approximation — a cheap formula that gets extremely close to the real physics without needing anything exotic:


float3 F_Schlick(float3 F0, float VdotH)
{
    return F0 + (1 - F0) * pow(saturate(1 - VdotH), 5);
}

saturate(x) is HLSL shorthand for clamp(x, 0, 1). VdotH is 1 when V lines up exactly with H (a near-straight-on view) and drops toward 0 as the view becomes grazing, so 1 - VdotH grows toward 1 exactly when the angle gets shallow.

Worked trace for a dielectric with F0 = 0.04: looking straight on, VdotH ≈ 1, so F = 0.04 + 0.96 * (0)^5 = 0.04 — only 4% reflective, matching intuition (you mostly see the material's own diffuse color). Looking at a shallow, grazing angle, VdotH ≈ 0.05, so (1 - 0.05)^5 = 0.95^5 ≈ 0.774, giving F = 0.04 + 0.96 * 0.774 ≈ 0.783 — suddenly 78% reflective. That is the same material, same lighting, same code — only the viewing angle changed, and the surface went from "barely reflective" to "almost a mirror."

Common mistake Forgetting that F0 must be a float3 (a color), not a single number, once metals are involved. A dielectric's F0 is colorless (all three channels equal, around 0.04), but a metal's F0 is tinted — gold's F0 is a warm yellow-orange, not gray. Using a single float for F0 quietly makes every metal in the scene look chrome-colored instead of showing its actual metal tint.

10. A Simplified PBR Shader: Putting It Together

Production engines compute specular PBR lighting with a formula called the Cook-Torrance model:

specular = (D * F * G) / (4 * (N.V) * (N.L)) D -- Normal Distribution term: what fraction of the microfacets are tilted exactly toward H. This is Section 7's roughness idea turned into a real number -- it replaces pow(N.H, shininess) with a curve shaped to match real, measured materials (almost every engine uses one called GGX, or Trowbridge-Reitz). F -- Fresnel term: Section 9's F_Schlick(F0, VdotH), exactly as already covered. G -- Geometry (shadowing-masking) term: accounts for microfacets blocking each other's incoming or outgoing light, which matters most at grazing angles -- this is part of what keeps the whole formula energy-conserving. The 4 * (N.V) * (N.L) on the bottom is a normalization factor from the underlying derivation -- not important to memorize.

You do not need to derive D and G by hand to understand what they are for, and real projects essentially never hand-roll them — Unity's Standard Shader and URP's Lit shader already implement this exact formula, and you drive it entirely through the Albedo, Metallic, and Smoothness (Smoothness is just 1 - Roughness) fields in the Inspector. What follows is a simplified, hand-written stand-in that captures the same shape using tools you already have from Sections 2–9 — not the exact production formula, but enough to see, and modify, every piece with your own hands:


Shader "Lesson/07_3_SimplifiedPBR"
{
    Properties
    {
        _Albedo    ("Albedo",    Color) = (0.8, 0.8, 0.8, 1)
        _Metallic  ("Metallic",  Range(0, 1))    = 0
        _Roughness ("Roughness", Range(0.02, 1)) = 0.5
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" }
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            fixed4 _Albedo;
            float  _Metallic;
            float  _Roughness;

            struct appdata { float4 vertex:POSITION; float3 normal:NORMAL; };
            struct v2f
            {
                float4 pos         : SV_POSITION;
                float3 worldNormal : TEXCOORD0;
                float3 worldPos    : TEXCOORD1;
            };

            v2f vert(appdata v)
            {
                v2f o;
                o.pos         = UnityObjectToClipPos(v.vertex);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos    = mul(unity_ObjectToWorld, v.vertex).xyz;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float3 N = normalize(i.worldNormal);
                float3 L = normalize(_WorldSpaceLightPos0.xyz);
                float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
                float3 H = normalize(L + V);

                float NdotL = saturate(dot(N, L));
                float NdotH = saturate(dot(N, H));
                float VdotH = saturate(dot(V, H));

                // Non-metals start near 4% reflective and colorless;
                // metals reflect their own albedo color instead (Section 9).
                float3 F0 = lerp(float3(0.04, 0.04, 0.04), _Albedo.rgb, _Metallic);
                float3 fresnel = F0 + (1 - F0) * pow(1 - VdotH, 5);

                // Stand-in for the D * G terms: rough surfaces spread the
                // highlight into a wide, dim hump; smooth surfaces squeeze
                // it into a tight, bright spot (Section 7's microfacet idea).
                float shininess = lerp(512.0, 2.0, _Roughness);
                float specular  = pow(NdotH, shininess) * NdotL;

                // Metals have (almost) no diffuse bounce (Section 8), so
                // scale diffuse down to zero as metallic goes to 1 -- this
                // is the energy-conservation budget from Section 6, made real.
                float3 diffuse = _Albedo.rgb * (1 - _Metallic) * NdotL;

                float3 color = (diffuse + specular * fresnel) * _LightColor0.rgb;
                return fixed4(color, 1);
            }
            ENDCG
        }
    }
}

Expected result: set _Metallic = 1 and _Roughness = 0.05 on a sphere, and it renders as a near-mirror with almost no visible flat base color, just a tight, bright, albedo-tinted highlight — chrome. Set _Metallic = 0 and _Roughness = 0.9 on the same mesh, and it renders as a soft, matte, mostly-diffuse ball with only a faint, broad sheen — rubber. Notice what did not happen: no _SpecColor to hand-tune, no per-material shininess guesswork. Three sliders, each with a real physical meaning, produce two convincingly different materials.

Tip The real GGX distribution formula, for reference: D = a^2 / (PI * ((N.H)^2 * (a^2 - 1) + 1)^2), where a = roughness^2. You do not need to memorize this — it does the same job as this section's pow(NdotH, shininess) stand-in (squeeze tightly around N.H = 1 for low roughness, spread widely for high roughness), just shaped to match real measured materials more closely. Squaring roughness before using it is also why the Roughness slider feels roughly linear to the eye instead of bunching all the visible change into one end of the slider.

11. Image-Based Lighting: Ambient From an Environment Map

Every shader so far only reacts to one directional light. Real scenes are also lit by the sky, nearby walls, and bounced light from everything around an object — and a shiny surface should faintly reflect all of that, not just show flat black wherever no direct light lands. This is the problem image-based lighting (IBL) solves.

An environment map (usually a cubemap — six square images stitched into a box around a point, together covering a full 360-degree view) captures what the surroundings look like from roughly the center of a scene, baked once ahead of time. In Unity this is a Reflection Probe — an object you place in the scene that captures a cubemap of its surroundings, either once ("baked") or continuously ("realtime"), and hands it to any nearby shader that asks for it.

environment cubemap (360-degree image of the sky/room, captured once into a reflection probe) +-------------------+ | sky / room | | captured all | | the way around | +-------------------+ ^ | R = reflect(-V, N) | ------------ | surface | | point | ------------ ^ | V (view direction) | camera Smooth surface --> sample a SHARP mip level --> crisp mirror reflection Rough surface --> sample a BLURRY mip level --> soft, spread-out reflection

IBL actually supplies two separate things, replacing the single flat UNITY_LIGHTMODEL_AMBIENT constant from Section 4 with something that actually looks like the room:


// Conceptual only -- Unity's built-in macros (UNITY_SAMPLE_TEXCUBE_LOD
// and friends) handle the real version of this for you.
float3 R   = reflect(-V, N);
float  mip = _Roughness * MAX_REFLECTION_MIP;

// Diffuse IBL: blurry average of the surroundings, tinted by albedo.
float3 iblDiffuse = SampleIrradianceMap(N) * albedo * (1 - metallic);

// Specular IBL via the split-sum: prefiltered color x BRDF LUT.
float3 prefiltered = SampleEnvironmentMap(R, mip);
float2 envBRDF     = SampleBRDFLUT(float2(NdotV, _Roughness)).rg; // (scale, bias)
float3 iblSpecular = prefiltered * (F0 * envBRDF.x + envBRDF.y);

// Ambient occlusion dims the AMBIENT term only -- never direct light.
float3 ambient = (iblDiffuse + iblSpecular) * ao;

Expected result: place a _Metallic = 1, _Roughness ≈ 0.1 sphere in a scene with a Reflection Probe, with no direct light hitting part of it at all — instead of going pure black in the unlit area, that side of the sphere shows a blurry, mirror-like reflection of the sky or room around it, exactly like a real chrome ball would.

Tip As a beginner you will not usually write raw cubemap-sampling HLSL by hand — Unity's Standard and URP Lit shaders already do this the moment you drop a Reflection Probe into the scene. What this section buys you is knowing why a shiny object suddenly picks up the color of its surroundings the moment a probe is added, instead of it looking like unexplained magic.

12. Why PBR Looks Consistent Under Any Lighting

Put the pieces from this lesson together and a pattern shows up: nothing about _Albedo, _Metallic, or _Roughness mentions a specific light, a specific scene, or a specific engine. They describe the material itself — what a real, physical version of this surface would do to any light that hits it. Compare that to Blinn-Phong's _Shininess and _SpecColor, which were tuned by eye against one particular lighting setup and often looked wrong the moment you moved the object into a different level with different lights.

That is the actual payoff of everything in this lesson: because the BRDF respects energy conservation (Section 6), because roughness is grounded in an actual physical model of the surface (Section 7) instead of an arbitrary exponent, because Fresnel is always active (Section 9), and because ambient light comes from a real picture of the surroundings instead of one flat constant (Section 11) — the exact same chrome material looks correct under a bright noon sun, inside a dim torch-lit cave, and next to a colored neon sign, with zero per-scene retuning. Light the scene once; every correctly authored PBR material just responds.

There is a practical bonus too: because albedo, metallic, and roughness are standardized, physically-grounded quantities rather than one engine's private magic numbers, the same texture set exported from a tool like Substance Painter looks correct — not identical, but correct — whether it lands in Unity, Unreal, or Blender. A Blinn-Phong shininess value of "64" never meant anything outside the one shader it was written for.

13. The One Equation Everything So Far Approximates: The Rendering Equation

Every model in this lesson — Lambert, Blinn-Phong, Cook-Torrance, IBL — is a different shortcut for the same underlying formula. In 1986 Jim Kajiya wrote it down in a single line, and all of real-time lighting is the craft of approximating it cheaply enough to run 60 times a second. In plain words:

outgoing light = light the + sum over EVERY incoming direction of toward the camera surface emits ( incoming light x BRDF x cos(theta) ) (from point P, itself (0 for direction V) non-glowing materials) Written with symbols (the "rendering equation"): Lo(P,V) = Le(P,V) + INTEGRAL over hemisphere ( f(P,L,V) * Li(P,L) * (N.L) ) dL ------- ------- ------------------------ -------- -------- ----- outgoing emitted "add up over every the incoming Lambert radiance incoming direction L" BRDF light cosine

Read it left to right: the light leaving a point toward your eye (Lo) is whatever the surface glows on its own (Le — zero for everything except lamps, screens, lava, and other emissive materials) plus the total of every ray arriving from the hemisphere of directions above the surface, each ray multiplied by two things: the BRDF (what fraction of light from that incoming direction bounces toward the camera — Section 6) and N·L (the same Lambert cosine from Section 2, because light arriving at a grazing angle is spread thinner). The ("integral") just means "add it up over every incoming direction" — the continuous version of a loop over infinitely many light directions.

Nothing in this lesson escaped this equation; each technique is just a way to make the "add up over every direction" part affordable:

You will almost never type this equation into a shader. Its value is as a map: any time a lighting result looks wrong, the cause is a poor approximation of one of its three pieces — the BRDF (wrong D/F/G, Section 14), the incoming light (missing IBL or bounce light, Section 11), or the cosine and energy bookkeeping (Section 6).

14. Inside Cook-Torrance: The D, F, G Terms in Full

Section 10 used pow(NdotH, shininess) as a stand-in and pointed at the real formula. Here is the actual Cook-Torrance BRDF that Unity's Standard shader, URP/HDRP Lit, and Unreal all use, with every term written out as valid HLSL you could paste into the shaders from earlier sections. There are exactly three functions — one each for D, F, and G — plus the assembly.

D — the GGX / Trowbridge-Reitz normal distribution. This is Section 7's "how scattered are the tiny mirrors" turned into a number: given a roughness, what fraction of the microfacets are tilted to point exactly at H (and so bounce the light straight at your eye)?


// UNITY_PI is defined by UnityCG.cginc; use 3.14159265 elsewhere.
float D_GGX(float NdotH, float roughness)
{
    float a  = roughness * roughness;      // alpha = roughness^2 (Disney remap)
    float a2 = a * a;
    float d  = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
    return a2 / (UNITY_PI * d * d);
}

Worked numbers (roughness 0.5, so α = 0.25): at the exact center of the highlight NdotH = 1, and D_GGX(1, 0.5) = 5.09. Tilt the surface so NdotH = 0.9 and it collapses to 0.34 — a 15× drop for a tiny change in angle, and that steep peak is what your eye reads as a highlight. Now make it glossy (roughness 0.1): D_GGX(1, 0.1) = 3183, but D_GGX(0.99, 0.1) = 0.08. A near-mirror concentrates almost all its reflected energy into a pinpoint — a towering, razor-thin spike — which is exactly why chrome shows a tiny blinding glint instead of a soft glow.

G — the Smith geometry (shadowing-masking) term. At grazing angles microfacets block each other: some sit in the shadow of a neighbor (light can't reach them), some are masked (their reflection can't reach the eye). G is the surviving fraction. The standard real-time choice is Schlick-GGX applied twice via Smith's method — once for the light direction, once for the view:


float G_SchlickGGX(float NdotX, float k)
{
    return NdotX / (NdotX * (1.0 - k) + k);
}

float G_Smith(float NdotV, float NdotL, float roughness)
{
    float r = roughness + 1.0;
    float k = (r * r) / 8.0;               // direct-light remap; use k = a*a/2 for IBL
    return G_SchlickGGX(NdotV, k) * G_SchlickGGX(NdotL, k);
}

Worked numbers (roughness 0.5): looking fairly head-on, NdotV = NdotL = 0.5 gives G = 0.61 — most microfacets are visible. At a grazing NdotV = NdotL = 0.1, G = 0.08 — nearly all of them shadow each other, so G darkens the specular right at the silhouette. This is the term that stops Fresnel brightening (Section 9) from making edges reflect impossibly hard: Fresnel pushes reflectance up at grazing angles while G pulls it back down, and together they match what real measured surfaces do.

F — Fresnel is exactly F_Schlick(F0, VdotH) from Section 9, unchanged. Now assemble all three into the full Cook-Torrance specular term and split the leftover energy into diffuse — this is the energy budget from Section 6 made literal:


float3 F0 = lerp(float3(0.04, 0.04, 0.04), albedo, metallic);

float  D = D_GGX(NdotH, roughness);
float  G = G_Smith(NdotV, NdotL, roughness);
float3 F = F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0);

// Cook-Torrance specular = D * F * G / (4 (N.V)(N.L)).
// The + 1e-4 stops a divide-by-zero firefly when N.V or N.L hits 0 at the edge.
float3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 1e-4);

// Energy conservation: kS is the specular fraction (= Fresnel), so at most
// (1 - kS) is left for diffuse -- and metals get no diffuse at all.
float3 kS = F;
float3 kD = (1.0 - kS) * (1.0 - metallic);
float3 diffuse = kD * albedo / UNITY_PI;

float3 Lo = (diffuse + specular) * _LightColor0.rgb * NdotL;

Two details trip people up. First, the / UNITY_PI on the diffuse term is physically correct — the Lambert BRDF is albedo / π, not albedo — but many game shaders quietly drop it and fold the missing π into the light's intensity, which is why the simplified shader in Section 10 left it out. Second, kD = (1 - F)(1 - metallic) is the real reason those earlier shaders multiplied diffuse by (1 - metallic): it is the energy budget. Whatever the specular Fresnel reflects (kS) is subtracted from the diffuse (kD), so the two can never sum past the incoming light no matter what sliders an artist sets.

Common mistake Dropping the + 1e-4 (or an equivalent max) in the specular denominator. At the exact silhouette of an object N·V approaches 0, the 4 * NdotV * NdotL divisor approaches 0, and the specular value explodes into single blinding white pixels — fireflies — that flicker as the camera moves. Clamping the denominator away from zero (or using a "visibility" formulation of G that folds the denominator in) removes them.

15. Working in Linear Space: sRGB, Gamma, and Why the Math Demands It

Section 6's tip said the energy math "only comes out correct if colors are stored as linear light values." Here is what that actually means, and why skipping it silently ruins every formula above.

A monitor does not display brightness linearly. Feed it the value 0.5 and it emits roughly 22% of full brightness, not 50% — screens follow an sRGB curve, close to a gamma of 2.2. To compensate, image files (PNGs, JPEGs, the albedo textures an artist paints) are sRGB-encoded: their stored numbers are pre-bent so that after the monitor's curve they look right. That encoding is baked into essentially every color image you have ever seen.

sRGB (what's stored / shown) <--gamma 2.2--> linear (actual light energy) decode (sRGB -> linear): linear = pow(srgb, 2.2) encode (linear -> sRGB): srgb = pow(linear, 1.0/2.2) // 1/2.2 = 0.4545 srgb 0.5 -> linear 0.2176 "middle gray" is only ~22% of the light srgb 0.25 -> linear 0.0474 linear 0.2176 -> srgb 0.5000 (round-trips exactly)

Here is the problem: light adds up linearly, but sRGB values do not. Every + in every shader above — two lights summing, diffuse plus specular, ambient plus direct — is real physical light energy being added, and that addition is only correct on linear numbers. Add two sRGB-encoded values directly and the result means nothing physical (it lands too dark). So the pipeline must be:

1. DECODE every color (sRGB) texture to linear when it is sampled 2. do ALL lighting math (Sections 2-14) in linear space 3. ENCODE the final result back to sRGB just before writing it to the screen Unity does all three automatically the moment Color Space = Linear (Player Settings > Other Settings). The GPU even decodes and encodes for free in hardware -- you just have to turn it on.

Which textures get decoded, and which must not — this is the part people get wrong. A texture is decoded from sRGB only if it stores a color a human picked by eye. A texture that stores numbers must be left linear:

The true sRGB curve is a piecewise function with a small linear segment near black; pow(x, 2.2) is the standard approximation and is what most explanations (this one included) use. The hardware sRGB path uses the exact curve, so prefer flipping the texture's sRGB checkbox over doing pow by hand.

16. HDR and Tonemapping: Fitting Bright Light Into a 0–1 Screen

Real light has no upper limit. The sun is thousands of times brighter than a sheet of paper; a bright window beside a dim wall can differ by 100×. Once you sum several lights plus specular plus IBL, the linear result routinely blows past 1.0 — a chrome glint might compute to 40.0. But a screen channel maxes out at 1.0. Naively clamping everything above 1.0 to pure white throws away all that range and gives you flat, plasticky blowouts (the same overexposed look Section 5 blamed on Blinn-Phong).

The fix has two parts. First, HDR (High Dynamic Range) rendering: do all the lighting into a floating-point render target that can hold values far above 1.0, instead of an 8-bit one that clamps. Second, tonemapping: a curve applied at the very end that gently compresses the open-ended [0, ∞) range down into the displayable [0, 1], keeping detail in both shadows and highlights the way real film does. The two curves you will meet most often:


// Reinhard -- the simplest tonemap. Cheap, but desaturates and flattens highlights.
float3 Reinhard(float3 c)
{
    return c / (1.0 + c);
}

// ACES filmic (Narkowicz 2015 fit) -- the modern default in Unity and Unreal.
// Deeper contrast, filmic highlight rolloff, keeps color better than Reinhard.
float3 ACESFilmic(float3 x)
{
    const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
    return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
}

Worked numbers, feeding the same HDR inputs through both curves:

HDR in Reinhard out ACES out ------ ------------ -------- 0.18 0.153 0.267 (18% "mid gray" card) 1.00 0.500 0.804 4.00 0.800 0.973 10.00 0.909 1.000 Nothing ever slams to pure white by clamping -- both curves ROLL OFF toward 1.0 smoothly, so a 4.0 highlight and a 10.0 highlight stay distinguishable instead of both becoming solid white. ACES holds more contrast and saturation through the midtones; Reinhard is flatter and washes highlights gray.

You usually also apply an exposure multiply before the tonemap (color *= exposure;) — the digital version of a camera's exposure dial, letting you brighten a dim night scene or pull back a blown-out desert without touching a single light. The complete tail of the frame is therefore: linear HDR lighting → multiply by exposure → tonemap → encode to sRGB (Section 15) → display. Get the order wrong — tonemapping after the sRGB encode, say — and the curve operates on the wrong numbers and the whole image shifts.

Tip In Unity you rarely write these by hand: enable HDR on the camera and add a Tonemapping override (Neutral or ACES) in a post-processing Volume. Knowing the curves still matters — "why does my bright emissive turn white and gray instead of staying colored?" is almost always Reinhard desaturation, fixed by switching to ACES.

17. Common Lighting Artifacts and How to Fix Them

Correct-looking PBR math still produces recognizable, named failures on real hardware. Four you will hit, each with its cause and its standard fix:

Banding. A smooth, dim gradient — a sunset sky, a soft IBL falloff, a subtle spotlight pool — shows visible stair-step stripes instead of a clean fade. Cause: an 8-bit screen channel has only 256 levels, and across a wide gentle gradient the jump between two adjacent levels grows big enough for your eye to catch as a hard edge. Fix: dithering — add a tiny sub-pixel noise (about ±1/255) just before the 8-bit quantization, trading one visible hard edge for invisible fine noise the eye happily averages away.


// Add just before writing the final 8-bit color. screenPos = pixel coordinates.
float dither = frac(sin(dot(screenPos.xy, float2(12.9898, 78.233))) * 43758.5453);
color.rgb += (dither - 0.5) / 255.0;

Specular aliasing (shimmer / crawling). A surface with fine normal-map detail, or a glossy object seen at a distance, sparkles and crawls with flickering white dots as the camera moves. Cause: one pixel covers many microfacet orientations, but the shader samples a single normal and a single (low) roughness — it undersamples that razor-thin GGX spike from Section 14, so the highlight pops in and out between frames. Fix: raise roughness wherever the normals vary faster than the pixels can resolve. Mip-mapped normal maps feed a normal-variance-to-roughness step (Toksvig, or the "Specular AA" toggle in Unity HDRP / Unreal) that automatically roughens minified surfaces; a minimum-roughness clamp and temporal anti-aliasing (TAA) help too. The one-line intuition: if you can't resolve the detail, blur the highlight instead of letting it flicker.

Wrong gamma. The whole scene looks murky and too dark, midtones crushed and muddy — or lights that should sum to "twice as bright" barely change anything. Cause: lighting math done on sRGB values, or the final linear→sRGB encode skipped (Section 15). A linear 0.25 written straight to an sRGB screen displays as pow(0.25, 2.2) ≈ 0.047 — about five times too dark. Fix: Color Space = Linear, color textures flagged sRGB, data textures flagged linear. This is the single most common reason a from-scratch shader "looks off" in a way you can't quite name.

Fireflies. Isolated, blindingly bright single pixels that flicker along edges and on glossy surfaces. Cause: the 4·(N·V)·(N·L) denominator in the Cook-Torrance specular (Section 14) approaching zero at grazing angles, plus very bright HDR values landing on a lone sample. Fix: the + 1e-4 denominator clamp from Section 14, clamping maximum luminance before it enters any bloom/blur pass, and TAA to average across frames.

Tip Three of these four (banding, wrong gamma, fireflies) are invisible on a bright test scene and only surface in dim areas, at silhouettes, or after post-processing amplifies them — which is exactly why they slip into a build unnoticed. When something "looks slightly off but I can't say why," check gamma first, then scan the darks for banding and the edges for fireflies.

18. Glossary

19. Exercises

Exercise 1 — Add Specular to a Diffuse-Only Shader The frag() function below only computes Lambert diffuse, copied from Section 2. Extend it to also add a Blinn-Phong specular term, using a new _Shininess property (a Range(8, 256) float) and reusing _Color as the specular color. You will need to compute V and H yourself — i.worldPos is already available on v2f.

fixed4 _Color;

fixed4 frag(v2f i) : SV_Target
{
    float3 N = normalize(i.worldNormal);
    float3 L = normalize(_WorldSpaceLightPos0.xyz);

    float NdotL = max(0, dot(N, L));
    fixed3 diffuse = _Color.rgb * _LightColor0.rgb * NdotL;

    return fixed4(diffuse, 1);
}
Show answer

fixed4 _Color;
float  _Shininess;

fixed4 frag(v2f i) : SV_Target
{
    float3 N = normalize(i.worldNormal);
    float3 L = normalize(_WorldSpaceLightPos0.xyz);
    float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
    float3 H = normalize(L + V);

    float NdotL = max(0, dot(N, L));
    float NdotH = max(0, dot(N, H));

    fixed3 diffuse  = _Color.rgb * _LightColor0.rgb * NdotL;
    fixed3 specular = _Color.rgb * _LightColor0.rgb * pow(NdotH, _Shininess);

    return fixed4(diffuse + specular, 1);
}

V comes from the camera's world position minus this pixel's world position, and H is the normalized sum of L and V (Section 3). Reusing _Color for both diffuse and specular is a simplification — Section 5 explains why a real shader normally keeps those separate (or, in the PBR shaders from Sections 8–10, derives specular tint from albedo and metallic instead of a free-standing color).

Exercise 2 — Pick Metallic and Roughness Values For each real-world material below, write down a reasonable _Metallic and _Roughness value (each 0–1) and explain your choice in one sentence: (a) a polished chrome car bumper, (b) a rubber car tire, (c) a smooth, glazed ceramic plate, (d) an old iron railing with patches of flaking rust over bare metal.
Show answer

(a) Chrome bumper: Metallic ≈ 1.0, Roughness ≈ 0.05. It is bare polished metal, so it has essentially no diffuse term and its microfacets are almost perfectly aligned, giving a near-mirror reflection.

(b) Rubber tire: Metallic ≈ 0.0, Roughness ≈ 0.9. Rubber is a dielectric (Metallic 0) with a very scattered, matte microfacet surface, so it only shows a faint, broad sheen rather than a sharp highlight.

(c) Glazed ceramic plate: Metallic ≈ 0.0, Roughness ≈ 0.1–0.2. The glaze is a non-metal (Metallic 0) but a smooth, glassy coating, so its microfacets are fairly well aligned — low roughness, giving a fairly tight, bright highlight despite not being metal at all.

(d) Rusted iron railing: this one is not a single value — it needs a _Metallic texture, not a flat number. The bare metal patches should be near Metallic 1.0 with low-to-medium roughness (scratched, not mirror-polished), while the rust patches should be near Metallic 0.0 with high roughness (rust is an oxide, a non-metal, and it looks dry and matte). This is exactly why Metallic and Roughness are painted as per-pixel textures in Section 8 rather than set once per object — real materials are rarely uniform.

Exercise 3 — Find the Bug The _Metallic slider on the shader below is set to 1 (meant to be pure chrome), but instead of looking like a mirror, the sphere renders with a dull, washed-out gray sheen spread evenly across its whole surface — not the sharp, tight highlight a real chrome ball should have. Find the line causing this, explain why it breaks a rule from Section 6, and fix it.

fixed4 frag(v2f i) : SV_Target
{
    float3 N = normalize(i.worldNormal);
    float3 L = normalize(_WorldSpaceLightPos0.xyz);
    float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);
    float3 H = normalize(L + V);

    float NdotL = saturate(dot(N, L));
    float NdotH = saturate(dot(N, H));
    float VdotH = saturate(dot(V, H));

    float3 F0 = lerp(float3(0.04, 0.04, 0.04), _Albedo.rgb, _Metallic);
    float3 fresnel = F0 + (1 - F0) * pow(1 - VdotH, 5);

    float shininess = lerp(512.0, 2.0, _Roughness);
    float specular  = pow(NdotH, shininess) * NdotL;

    float3 diffuse = _Albedo.rgb * NdotL;
    float3 color = (diffuse + specular * fresnel) * _LightColor0.rgb;
    return fixed4(color, 1);
}
Show answer

The bug is this line:


float3 diffuse = _Albedo.rgb * NdotL;

It is missing the (1 - _Metallic) factor from Section 10. As written, every material gets a full, unscaled diffuse term no matter what _Metallic is set to — so even at _Metallic = 1, the shader still adds a full flat, matte diffuse bounce on top of the specular reflection. Section 8 explained that a real metal has (almost) no diffuse term at all — 100% of the light it reflects is specular. Adding both a full diffuse term and a full specular term at the same time also breaks the energy-conservation budget from Section 6: the surface is now reflecting more total light than a real material physically could. The fix restores the missing factor:


float3 diffuse = _Albedo.rgb * (1 - _Metallic) * NdotL;

With this fix, at _Metallic = 1, (1 - _Metallic) becomes 0, the diffuse term disappears entirely, and only the tight, Fresnel-tinted specular highlight remains — a proper mirror-like chrome look.

Exercise 4 — Assemble the Full Cook-Torrance Specular You are given working D_GGX(NdotH, roughness) and G_Smith(NdotV, NdotL, roughness) from Section 14, plus F_Schlick(F0, VdotH) from Section 9. Using them, write the fragment-shader lines that compute (a) the Cook-Torrance specular term, (b) the energy-conserving diffuse term with its kD/kS split, and (c) the final combined Lo. Assume albedo, metallic, and the four dot products are already computed, and that F0 is built with lerp(0.04, albedo, metallic). Which single quantity must you guard against dividing by zero, and how?
Show answer

float  D = D_GGX(NdotH, roughness);
float  G = G_Smith(NdotV, NdotL, roughness);
float3 F = F_Schlick(F0, VdotH);

// (a) specular = D * F * G / (4 (N.V)(N.L)); +1e-4 guards the divide.
float3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 1e-4);

// (b) energy split: kS is the specular fraction, kD is what's left,
//     and metals get no diffuse at all.
float3 kS = F;
float3 kD = (1.0 - kS) * (1.0 - metallic);
float3 diffuse = kD * albedo / UNITY_PI;

// (c) combine, apply the light color and the Lambert cosine.
float3 Lo = (diffuse + specular) * _LightColor0.rgb * NdotL;

The quantity to guard is the specular denominator 4 * NdotV * NdotL: at the object's silhouette NdotV (and near the terminator, NdotL) approaches 0, so the divisor approaches 0 and the specular blows up into fireflies. Adding + 1e-4 (or wrapping the divisor in a max(..., 1e-4)) keeps it finite. Note the diffuse is albedo / π, not albedo — that is the physically correct Lambert BRDF (Section 14).

Exercise 5 — Diagnose the Dark, Banded Scene A student ports their PBR shader to a fresh project. Every material renders far too dark and muddy, the midtones look crushed, and the dim gradient on a wall behind the character shows visible stair-step stripes. No line of the lighting math is wrong. Name the two separate problems, the one project setting behind the darkness, and the one-line shader trick for the stripes. Back the "too dark" claim with a number for a linear value of 0.25.
Show answer

Problem 1 — wrong gamma (Section 15). The project is in Gamma color space (or the final linear→sRGB encode is missing), so linear light values are written straight to an sRGB screen. A linear 0.25 then displays as pow(0.25, 2.2) ≈ 0.047 — about five times too dark — which is exactly the murky, crushed-midtone look. Fix: Player Settings > Color Space = Linear, mark color textures as sRGB and data textures (normal/roughness/metallic/AO) as linear.

Problem 2 — banding (Section 17). The smooth dim gradient has more distinct brightness steps than an 8-bit channel's 256 levels can represent, so adjacent levels show as hard stripes. Fix: dither just before output — color.rgb += (dither - 0.5) / 255.0; — trading the visible edges for invisible noise.

The two are independent: fixing the color space removes the darkness but not the banding, and dithering hides the banding but does nothing about the gamma. A correct build needs both.

← Back to all chapters