Colors Original
One fragment shader. The light splits into twenty color temperatures instead of wavelengths, so it separates warm and cool without a rainbow.
The study runs live under its video, in your browser, with its own controls, so you can switch the idea off and on and see what it does.
The first version of this study split white light into wavelengths, the way a prism does. It came out as a rainbow, and a rainbow glowing on black is the look every AI product already has. The brief became: separate light into its parts without a spectrum.
The answer is color temperature. Instead of wavelengths, the ribbon is the sum of twenty bands of blackbody light, from candle warm at 1900 K to overcast cool at 10000 K. Each band sits a little off the curve along its normal, so warm light gathers on one edge and cool light on the other.
Why this never turns into a rainbow: the blackbody line only runs from amber through white to sky blue. There is no green and no magenta anywhere on it. The bands can separate as far as you like, and the edges stay warm and cool. Proof shows it directly: the left half has the separation off, the same twenty bands stacked; the right half pulls them apart.
Each band's color comes from Tanner Helland's blackbody fit, which turns a temperature into sRGB. The shader linearizes it, because light adds in linear space, not in the gamma-encoded values a screen stores.
vec3 kelvin(float K) { float t = K / 100.0; float r = t <= 66.0 ? 255.0 : 329.698727446 * pow(t - 60.0, -0.1332047592); float g = t <= 66.0 ? 99.4708025861 * log(t) - 161.1195681661 : 288.1221695283 * pow(t - 60.0, -0.0755148492); float b = t >= 66.0 ? 255.0 : (t <= 19.0 ? 0.0 : 138.5177312231 * log(t - 10.0) - 305.0447927307); return pow(clamp(vec3(r, g, b) / 255.0, 0.0, 1.0), vec3(2.2));}The bands are spaced logarithmically, closer together at the warm end, where a small change in temperature is a large change in color. Each band is a soft gaussian around its own offset curve, with a long halo for Soft light.
for (int i = 0; i < N; i++) { float u = (float(i) + 0.5) / float(N); // 0 warm .. 1 cool float K = 1900.0 * pow(10000.0 / 1900.0, u); // log spaced, like the eye reads temperature vec3 c = kelvin(K); float dl = d - disp * sep * (u - 0.5) * 2.0; // warm below the curve, cool above float s = sig * mix(0.92, 1.22, u); // cool light blooms wider, like skylight float core = exp(-(dl * dl) / (s * s)); float halo = uSoft * 0.45 * exp(-abs(dl) / (s * 2.8)); acc += (core + halo) * c; norm += c;}acc /= norm; // stacked bands sum to neutral whiteLook at the last line of the loop. Dividing the sum by the sum of the band colors white balances the stack: where every band overlaps, the result is pure neutral white. Nothing in the shader draws a white core. It appears because the bands meet, which is why it sits exactly where the separation is smallest.
Two details keep the loop clean. Every moving term runs at a whole fraction of the main wave's rate, two thirds and one third, so after three full turns of the main wave every term is back where it started, and the ten second loop has no jump. And one line of interleaved gradient noise dithers the falloff, which removes the 8 bit banding a soft gradient on black would otherwise show.
// interleaved gradient noise (Jimenez 2014): one line of dither, kills 8-bit bandingfloat ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); } col += (ign(gl_FragCoord.xy) - 0.5) / 255.0;