Foundations, part 5

Linear color, HDR and taming light brighter than white

Why shader math works on light instead of screen numbers, what happens to a glow brighter than white, and how I tame it without losing its color.

  • Part 57 min dose
  • Beginner

My spells and eye shaders glow, and their light often goes far above what a screen can show. How I bring it back down decides whether you see a flat white band or a real glow, and whether a purple glow stays purple. Let’s look at linear light, values above 1, tone mapping and a cap that keeps the color.

One glow shown two ways
Drag the split line, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

hero.hlsl draws the demo above
Fig. 1

The same light, shown two ways. Drag the line. On the left every value above 1 is cut off, and the ring turns into a flat white band. On the right the light is gently squeezed into the range a screen can show, with a soft halo on top, like in a VRChat world with bloom.

Stored numbers are not light

Inside a shader, color is light. Black is 0, the brightest white a screen shows is 1, and 0.5 is half as much light. That straight scale is called linear light. Image files play by different rules. Most store each channel (red, green, blue) as a level from 0 to 255, using sRGB. It’s a curve that spends more levels on dark tones, where eyes notice small changes.

How sRGB stores light

Turn on JavaScript to run this demo.

An amount of light, from black (0) to white (1).0.50
Code for the plot above
  • f1(x):linearToSrgb(float3(x, x, x)).x
  • f2(x):x
Fig. 2

Half the light, 0.5, is stored as 0.735. Now slide Light to 0.03, a faint glow, and you’ll see sRGB store it at level 48 of 255. Stored as plain light it would get level 8, far too few steps for a smooth haze.

And here’s the catch. Mixing the stored numbers gives the wrong answer. A soft edge, a blur or a fade between two colors goes dark in the middle. So the math happens in linear light. VRChat projects use Unity’s Linear color space, so color textures are turned into light when the shader reads them. On its way to the screen, the result is turned back into sRGB.

Mixing numbers or mixing light

Turn on JavaScript to run this demo.

The color on the left.
The color on the right.
mix.hlsl draws the demo above
Fig. 3

Red and green both start bright. The top strip mixes their sRGB numbers and goes muddy and dark in the middle. The bottom strip mixes their light and stays bright the whole way. Try a few other pairs too, like blue and yellow.

Brighter than white

Nothing in the math stops at 1. A lamp can be 8 times brighter than white, and on PC a VRChat camera usually keeps that value. It renders in HDR, short for high dynamic range. The screen still tops out at 1, though, so 2, 4 and 8 all look like the same white.

What tells them apart is bloom. Many worlds run post processing, a last step over the whole picture. Bloom takes the light above a threshold, blurs it and adds it back as a halo. The brighter the light, the bigger the halo. With bloom my glows really glow, and without it they still read as bright shapes. That’s why I make them HDR.

Five lamps and their bloom

Turn on JavaScript to run this demo.

Spread the light above the threshold into a soft halo, as a world's bloom does.
Only light above this value blooms.1.0
Stripe every lamp that holds more light than the screen can show.
hdr.hlsl draws the demo above
Fig. 4

The bloom here is a stand-in, worked out from the lamps themselves. Turn on Mark above 1 to see which lamps hold more light than a screen can show. Then move Threshold to 0, as Silent’s guide to VRChat world post processing recommends, and watch the dim lamps get a faint halo too.

Bending light back into range

Before the picture reaches the screen, every value has to land between 0 and 1. A tone map is the curve that gets it there. Here are three common ones:

clip=min⁡(x,1)Reinhard=x1+xACES fit=x (2.51x+0.03)x (2.43x+0.59)+0.14\begin{aligned} \text{clip} &= \min(x, 1) \\ \text{Reinhard} &= \frac{x}{1 + x} \\ \text{ACES fit} &= \frac{x\,(2.51x + 0.03)}{x\,(2.43x + 0.59) + 0.14} \end{aligned}

xx is one channel of the light, and min⁡\min picks the smaller of two numbers, so clip flattens everything above 1. Reinhard never quite reaches 1, so whites go grey. ACES is a color system made for film, and the ACES fit is a short formula that follows the shape of its curve, a little brighter. It bends the brightest values down softly and reaches white at about 7. And keep in mind that in VRChat the world picks the tone map, not the avatar.

Three ways to bend light

Turn on JavaScript to run this demo.

How much light one channel holds.2.00
Code for the plot above
  • f1(x):min(x, 1.0)
  • f2(x):x / (1.0 + x)
  • f3(x):tonemapACES(float3(x, x, x)).x
Fig. 5

Slide Light x to 1 and clip already shows full white, while the ACES fit shows about 0.8 and Reinhard only 0.5.

This next bit is a little tricky, so let’s take it slowly. In these demos, and in many games, the curve runs on each channel separately. A strong purple has much more blue than green. As it gets brighter, its blue reaches the top first while red and green keep climbing. So the color drifts to pink and then to white. On a glow, that’s the familiar white hot core with a colored halo.

A purple tube growing brighter

Turn on JavaScript to run this demo.

How the light is bent into the 0 to 1 a screen can show.
Add a soft halo from the light, as a world's bloom does.
The tube's color at brightness 1.
lib.hlsl and glow.hlsl draw the demo above
Fig. 6

Brightness rises from 0 on the left to 10 on the right. Switch Tone map and compare: Clip turns the tube pink and then flat white, Reinhard leaves it dull, and the ACES fit keeps a white core inside a purple glow.

A cap that keeps the hue

Some worlds use harsh bloom, with a threshold near 1 and a strong halo. In a dark world, a glow at 10 or 20 then blinds everyone nearby. Not very friendly. So my effects cap their own output with a Max brightness MM.

The tempting fix is clamping, cutting each channel off at MM the way clip cuts at 1. But that repeats the problem from above. Blue stops at MM, red keeps going, and the color shifts. Instead, I scale the whole color down until its brightest channel equals MM:

c′=c⋅min⁡ ⁣(1, Mmax⁡(cr,cg,cb))c' = c \cdot \min\!\left(1,\ \frac{M}{\max(c_r, c_g, c_b)}\right)

c′c' is the capped color, cc the color before the cap, cr,cg,cbc_r, c_g, c_b its three channels, and max⁡\max picks the largest. Every channel is multiplied by the same number, so the ratio between them stays put, and with it the color. Only the brightness changes. The min⁡\min with 1 leaves colors already below MM alone. And that’s the whole trick.

Clamp it or keep the hue

Turn on JavaScript to run this demo.

How the light is bent into the 0 to 1 a screen can show.
How the shader limits its own output before the world sees it.
The most light the shader may send out.1.2
Add a soft halo from the light, as a world's bloom does.
The tube's color at brightness 1.
glow.hlsl draws the demo above
Fig. 7

With Clamp each channel the right end turns white and the glow drifts to pink. Now switch Limit to Keep the hue. The tube stops at MM and stays purple. Raise Max brightness M and it turns paler, because the tone map still lifts each channel on its own.

In practice

Glowing shaders, from spells to eyes, send out HDR light (light above 1) and leave the rest to the world: its bloom, its tone map, or none at all. Quest worlds have no post processing, so there the light is clipped. It’s worth checking a glow twice, once in a dark world with bloom and once in a plain one.

In Unity, you pick a color property in sRGB and it’s turned into linear light for you. A color with an HDR picker also has an Intensity setting, and each step up doubles the light. Intensity 2 on white is four times as bright. Watch out for masks, though. A mask or other data texture needs sRGB (Color Texture) unticked in its import settings.

Sky Rift, my spell that cracks the sky, ends its sky and fog with the cap from above. Its material shows it as a Max Brightness slider from 0 to 10, set to 4.

My glass shaders also clean up broken pixels. A NaN is a value that is not a number, such as zero divided by zero. Bloom spreads one huge pixel into a blinding square, and a NaN into a black one. So my glass keeps every channel between 0 and 30, the bloom clamp Silent’s guide recommends for worlds. A channel at 30 already shows at full strength. On PC, max with a NaN gives back the other number, so the line also turns a NaN into 0:

DocMe_GlassCore.cgincRuns in Unity only
#define GLASS_MAX_HDR 30.0
// ...
float3 glassSanitize(float3 c)
{
    return min(max(c, 0.0), GLASS_MAX_HDR);
}

Credits

Takeaways

  • Do your color math on linear light. Unity turns it into sRGB once, at the end.
  • Light above 1 is real. Bloom and tone mapping show it, and a plain screen clips it.
  • To limit a color, scale all three channels by the same number, and the hue stays.
Search the lessons

Type any word from the lessons, like normalize or smoothstep. The start of a word is enough.