Colour & Gamma
Split one lit sphere down the middle and light the two halves in different colour spaces.
What you should come away with: Why lighting maths done on sRGB numbers is wrong, and why the mistake looks like a style rather than a bug.
Assumes: Light & Normals. It will still make sense without it, but that one comes first.
A framebuffer holds numbers between 0 and 1, and a display turns each of those numbers into an amount of light. The relationship between the two is not a straight line. Write 0.5 and the screen emits roughly a fifth of the light it emits for 1.0 — and every shader on this site once multiplied colours as though it emitted half.
That is the bug this lab is about, and it is the one that survives longest in a renderer, because nothing it does looks like an error. Midtones that come out too dark read as mood. A terminator that falls off a cliff reads as contrast. Below, one sphere is split down the middle and both halves are lit by the same light with the same , in two different colour spaces, so the difference has an edge you can look at.
The number 0.5 is about a fifth of the light#
spends its 256 codes unevenly, on purpose. Your eye resolves far finer differences in the dark than in the light, so an encoding that spaced its codes evenly across the light would waste most of them at the bright end and band visibly in the shadows. sRGB stores roughly the 1/2.2 power of the light instead, which bunches the codes up where the eye is sensitive. It is a good encoding, and it is not a quantity you can do arithmetic with.
The strip along the bottom of the figure is the shortest proof of that. Its left third is alternating rows of black and white — half the pixels emitting nothing and half emitting everything, so half the light. Its middle third is the number 0.5 written straight into the framebuffer. Its right third is half the light, encoded. Step back from the screen until the rows blur into one tone, then compare the three.
- the dither
- 50.0% light
- the number 0.5
- 21.8% light
- half the light
- 0.730
At 2.2, half the light is the number 0.730, which is code 186 of 255. About three quarters of the available codes are spent below the halfway point of the light. That is the whole reason the encoding exists and the whole reason it is a trap: the midpoint of the numbers and the midpoint of the light are nowhere near each other, so a routine that averages two colours, or halves one, is not doing what its name says.
A cross-fade blends two colours by averaging their sRGB numbers frame by frame, and halfway through the transition the picture visibly dips darker than either end of the fade. What is happening?
The blurring has to happen in your eye, which sums light. Zoom the page out, or screenshot it and resize the file, and whatever resamples the image will almost certainly average those rows as numbers instead: a black row and a white row become 0.5, the dither collapses onto the middle patch, and the figure appears to prove the opposite of what it shows. Gamma-incorrect image scaling is the same bug as gamma-incorrect lighting, met in a different room.
The lab raises to a plain power of gamma rather than using sRGB’s actual piecewise transfer function, which has a short linear segment near black. The two agree closely away from the darkest few codes, and nothing here turns on the difference — the exponent is left on a slider precisely so it stops looking like a constant to memorise.
Every multiply in the shader is on the wrong quantity#
A diffuse shader has one instruction in it: take the surface’s base colour and multiply it by how much light lands there. The lab’s fragment shader computes that amount once, as uAmbient + uIntensity * max(dot(N, L), 0.0), and both halves of the sphere use the same number from the same normal. What differs is which colour it multiplies. The left half multiplies uBaseColor directly. The right half raises uBaseColor to the power of gamma first, multiplies there, and raises the result back by 1.0 / gamma on the way out.
Call that amount of light k, and the base colour c. The left half writes c · k. The right half writes (c^γ · k)^(1/γ), and because a power distributes over a product that is exactly c · k^(1/γ). The colour comes back untouched by the round trip; the entire difference between the two halves is k against k raised to 1/γ.
With the lab’s defaults the two are stark. The base colour’s red channel is 0.82, ambient is 0.05, intensity is 1. Where the surface turns away from the light, k is 0.05: the left half writes 0.041 and the right half writes 0.210, five times as much. At the point facing the light squarely, k is 1.05 and they write 0.861 and 0.838 — within three per cent of each other. The error is negligible in the highlight and enormous in the shadow, which is why the uncorrected sphere reads as a high-contrast lighting choice rather than as a broken one.
Turn the light up and the error changes sign#
Gamma is greater than one, so 1/γ is less than one, and raising a positive number to a power below one pulls it towards 1. Below k = 1 that means k^(1/γ) is larger than k and the corrected half is the brighter of the two. Above k = 1 the inequality reverses. At k = 1 exactly, both halves compute c and the divider has nothing to show.
k is ambient + intensity × lambert, so the two halves agree wherever lambert equals (1 − ambient) / intensity. At the default ambient of 0.05 and intensity of 1 that is lambert 0.95, a small cap sitting on the highlight. Raise the intensity and the cap opens out into a ring that sweeps across the sphere.
- left multiplies by
- 1.400
- right multiplies by
- 1.165
- they agree at lambert
- 0.704
This is why the mistake outlives code review. It is not a constant offset that somebody would flag as too dark; it is the wrong curve applied on the wrong side of the encoding, so it crushes the shadows and blows the highlights at once, and both of those are things a person can deliberately want. Doubling uIntensity only means doubling the light in the space where light adds, and the left half is not in it.
Set gamma to 1 and the two halves become the same shader#
The claim so far is that the encoding is the only difference between the halves, and that is worth testing rather than believing. toLinear(c, 1.0) is pow(c, 1.0), which is c; so is toSrgb(c, 1.0). At gamma 1 the corrected branch decodes with an identity, multiplies, and encodes with an identity — it is the uncorrected branch with three more instructions in it.
In a real renderer nobody writes those powers by hand at each multiply. A texture authored in sRGB is decoded once, when it is sampled, which is what an sRGB texture format is for and what the sampler hardware does at no cost. Lighting, blending and accumulation all happen in after that. The encode happens once, at the very end, when everything has been added up.
Which textures get that decode is a decision, not a default. A base colour map and an emissive map were authored by someone looking at a screen, so they hold encoded colour and have to be decoded. A normal map holds vectors, and a roughness, metalness or occlusion map holds a coefficient; none of those are colours, none of them went through an encoder, and raising them to the power 2.2 bends the numbers into nonsense — a normal-map texel of 0.5, which stands for a component of zero, comes back as 0.218 and stands for −0.56 instead. Marking a whole texture set sRGB because most of it looks like an image is a second version of the same mistake, running in the opposite direction.
Encoding before the end costs you a second bug on top of the first. Encode before you interpolate and the interpolation is wrong too, which is exactly what Gouraud shading would do — so in Light & Normals the vertex stage returns linear light, the carries linear light, and the fragment stage raises it by 1/2.2 at the last possible moment. That lab, and every other one on this site, was written the wrong way round first; this is the lab that corrected them.
What I got wrong here: every shader multiplied sRGB#
The shared lit shader behind four of these labs, and both of the shading lab’s own stages, took the base colour exactly as it was written, multiplied it by the light term and wrote the result straight to the framebuffer. The belief underneath is the one this lab exists to take apart: that a colour is a quantity, so scaling the number scales the colour. Nobody decided to skip the decode. The arithmetic looked like arithmetic.
The symptom was that every scene on the site came out slightly moody. Midtones sat darker than they should and terminators arrived as edges rather than roll-offs — the two effects the left half of the sphere above still shows on purpose. Both are things a person can deliberately want, so nothing on screen read as a fault and nothing threw. The arithmetic is valid; it was on the wrong quantity.
What caught it was building this lab. Two branches on one sphere a pixel apart, under the same light and the same Lambert term, left the difference nowhere to hide — and once the split sphere existed it was plain which side of it every other lab was rendering on. The grey test settled the rest: the number 0.5 and half the light are visibly different tones, and every shader here had been treating them as one.
The correction shipped with the lab, in commit de15cad: decode, multiply in light, encode on the way out — in the shared shader and in both of the shading lab’s stages, where for Gouraud the encode had to move after the interpolation rather than before it. What holds it now is the check moving a control changes the picture in test/render.smoke.ts, which drives this lab’s gamma slider to 1 in a real browser and measures the canvas against its own noise floor. At gamma 1 the two branches compute the same expression, so if the decode and the encode ever leave this lab’s fragment shader the slider stops changing anything and the check fails.
Now move all of it at once#
Below is the instrument, carrying every control the figures above were pinning down: the divider, the exponent, the intensity and ambient terms of the light, a toggle that corrects both halves so the divider disappears, the grey test, and a camera you can drag. The readout keeps the two numbers the whole lab rests on — what the number 0.5 is worth in light, and what half the light is worth as a number — recomputed at whatever gamma you have left the slider on.
Left of the divider: lit in sRGB · Right: lit in linear, then encoded
The numbers underneath
At gamma 2.20. Those two rows are the whole problem in one line: the midpoint of the numbers and the midpoint of the light are not the same place, so every multiply done on the former is a multiply done on the wrong quantity.
Move the divider across the sphere. Both halves compute the identical Lambert term from the identical normal and the identical light — the only difference is whether the colour is decoded to light before the multiply and encoded back afterwards. The uncorrected half is darker through its midtones and its terminator is an abrupt edge rather than a roll-off, which is why gamma bugs get mistaken for artistic choices and shipped.
This is a correction as much as a lesson. Every other lab on this site had it wrong — the shared lit shader multiplied encoded values and wrote them straight out. That has been fixed, which is why the shading lab now looks slightly different from the screenshots in the earlier changelogs.
Where that leaves you
You can now say why the number 0.5 is about a fifth of the light, and read crushed shadows sitting beside highlights that agree as the mark of a shader multiplying on the wrong side of the encoding.
This did not teach you colour spaces, only the transfer function that encodes one. The primaries and the white point never change anywhere on this site, and a value that comes out above 1 has nowhere to go but the clamp into an eight-bit framebuffer — deciding what should happen to it instead is a tone map, and a pipeline with no high dynamic range in it has no use for one.