Way back in episode 7 we explored color theory -- hue, saturation, lightness, complementary harmonies, analogous palettes. We mixed colors in JavaScript using HSL values and built palettes algorithmically. In episode 28 we took it further with data-driven palettes, extracting color schemes from images and datasets.
All of that was on the CPU, processing one pixel at a time, using the browser's built-in color model. On the GPU it's a completely different situation. GLSL works in RGB -- there's no built-in hsl() or hsv() function. If you want to rotate a hue, shift saturation, or remap brightness, you have to build the math yourself. Every pixel gets its own color computation, running in parallel, at 60fps. And the techniques for doing this go far beyond what CSS or Canvas can offer.
This episode is about controlling color in shaders. We'll convert between color spaces, build the cosine palette function that powers half of Shadertoy, quantize for retro effects, implement Photoshop-style blend modes, and compute color harmonies from pure math. By the end you'll have a toolbox of color functions you can drop into any shader project.
The most common color space conversion in shader art is RGB to HSV (hue, saturation, value). HSV separates "what color" (hue) from "how vivid" (saturation) and "how bright" (value), which makes it way easier to manipulate colors independently.
GLSL doesn't have this built in, so everyone uses the same pair of conversion functions. They've been copied from shader to shader so many times that they're essentially standard library at this point:
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
I know, they look like magic incantations. Let me break down hsv2rgb because it's the one you'll use more often. The hue (c.x) is a value from 0 to 1 representing the full color wheel. The function creates three offset ramps using fract(c.xxx + K.xyz) -- each color channel (R, G, B) gets a ramp shifted by 0, 1/3, and 2/3 around the hue circle. The abs(...*6.0 - 3.0) folds each ramp into a triangle wave. clamp constrains it to 0-1. Then saturation (c.y) controls how much color versus white, and value (c.z) controls overall brightness.
The rgb2hsv direction is trickier because it needs to find which channel is dominant (that determines the hue sextant) and compute the difference between max and min channels. The mix and step calls are branchless equivalents of if/else -- GPUs hate branching, so the shader community has converged on these branchless versions.
Here's a simple demo. Convert to HSV, rotate the hue, convert back:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// start with a warm gradient
vec3 baseColor = vec3(0.9, 0.4, 0.2);
baseColor = mix(baseColor, vec3(0.2, 0.5, 0.9), uv.x);
// convert to HSV, rotate hue with time
vec3 hsv = rgb2hsv(baseColor);
hsv.x = fract(hsv.x + u_time * 0.1);
vec3 color = hsv2rgb(hsv);
gl_FragColor = vec4(color, 1.0);
}
A gradient that slowly shifts through all hues over time. The fract on the hue keeps it wrapping -- hue 1.0 is the same as hue 0.0, so the rotation is seamless. This is the main reason to use HSV in shaders: hue rotation in RGB requires a 3x3 matrix multiplication. In HSV it's a single addition.
Once you're in HSV space, adjusting saturation and value is trivial. Desaturation (toward grayscale):
vec3 hsv = rgb2hsv(someColor);
hsv.y *= 0.3; // reduce saturation to 30%
vec3 muted = hsv2rgb(hsv);
Boost value (brighten):
hsv.z = min(hsv.z * 1.5, 1.0); // 50% brighter, clamped
You can also do position-dependent color shifts -- saturate the center, desaturate the edges:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float dist = length(uv);
// hue varies with angle
float hue = atan(uv.y, uv.x) / 6.28318 + 0.5;
hue = fract(hue + u_time * 0.05);
// saturation high in center, fades at edges
float sat = smoothstep(0.5, 0.1, dist);
// value also fades out
float val = smoothstep(0.55, 0.0, dist);
vec3 color = hsv2rgb(vec3(hue, sat, val));
gl_FragColor = vec4(color, 1.0);
}
A color wheel that glows from the center. Hue follows the angle (via atan), saturation peaks at center, brightness fades to black at the edges. Rotating slowly via u_time. This is one of those shaders that looks way more complex than it actually is -- it's just polar coordinates mapped to HSV.
This is the single most useful color function in shader art. Created by Inigo Quilez (who you might remember from the SDF episode -- the man is everywhere in shader world). One function, four parameters, infinite color palettes:
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
That's it. Five lines if you count the signature. t is the input value (0 to 1, or whatever range you want). a is the brightness offset, b is the contrast/amplitude, c is the frequency (how many times the palette cycles), and d is the phase offset per channel.
The cosine function oscillates smoothly between -1 and 1, so a + b * cos(...) oscillates between a - b and a + b. Different phase values for R, G, B mean each channel peaks at a different point on the t axis, creating color variation. It's elegant because a single scalar input produces a smooth, continuous color ramp with no lookup tables, no conditionals, no branches.
Here are some beautiful presets:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// pick a palette based on which horizontal band we're in
float band = floor(uv.y * 8.0);
float t = uv.x + u_time * 0.1;
vec3 color;
if (band < 1.0) {
// sunset: warm oranges to deep purples
color = palette(t, vec3(0.5,0.5,0.5), vec3(0.5,0.5,0.5),
vec3(1.0,1.0,1.0), vec3(0.0,0.33,0.67));
} else if (band < 2.0) {
// ocean: teals and deep blues
color = palette(t, vec3(0.5,0.5,0.5), vec3(0.5,0.5,0.5),
vec3(1.0,1.0,0.5), vec3(0.8,0.9,0.3));
} else if (band < 3.0) {
// neon: hot pink to electric cyan
color = palette(t, vec3(0.5,0.5,0.5), vec3(0.5,0.5,0.5),
vec3(2.0,1.0,0.0), vec3(0.5,0.2,0.25));
} else if (band < 4.0) {
// forest: greens and earthy browns
color = palette(t, vec3(0.5,0.5,0.3), vec3(0.4,0.4,0.3),
vec3(1.0,0.7,0.4), vec3(0.0,0.15,0.2));
} else if (band < 5.0) {
// pastel: soft pinks, lavenders, mints
color = palette(t, vec3(0.8,0.7,0.8), vec3(0.2,0.2,0.2),
vec3(1.0,1.0,1.0), vec3(0.0,0.1,0.2));
} else if (band < 6.0) {
// fire: reds, oranges, yellows
color = palette(t, vec3(0.5,0.4,0.2), vec3(0.5,0.3,0.2),
vec3(1.0,0.8,0.5), vec3(0.0,0.05,0.1));
} else if (band < 7.0) {
// ice: whites and pale blues
color = palette(t, vec3(0.7,0.75,0.9), vec3(0.2,0.2,0.3),
vec3(1.0,1.0,0.5), vec3(0.0,0.1,0.3));
} else {
// synthwave: magentas and cyans
color = palette(t, vec3(0.5,0.3,0.5), vec3(0.5,0.3,0.5),
vec3(1.0,1.0,1.0), vec3(0.75,0.55,0.2));
}
gl_FragColor = vec4(color, 1.0);
}
Eight palette presets, all from the same four-parameter function. Each one produces a completely different mood. The sunset palette smoothly transitions through every warm tone. The neon palette snaps between hot pink and electric blue. The forest palette stays earthy and grounded. All from tweaking four vec3 values.
I keep a cheat sheet of palette parameters taped next to my monitor. Whenever I start a new shader project, I pick a palette first and build everything around it. It's like choosing a key signature before composing music -- it sets the emotional tone for the entire piece.
The real power of cosine palettes shows when you feed them interesting input values. Instead of uv.x, feed in a noise field or a distance field:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
vec2 hash2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453) * 2.0 - 1.0;
}
float gradientNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = dot(hash2(i), f);
float b = dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
float c = dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
float d = dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * gradientNoise(p);
p *= 2.0;
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float n = fbm(uv * 4.0 + u_time * 0.15);
// map noise to a sunset palette
vec3 color = palette(n * 1.5 + 0.5,
vec3(0.5, 0.4, 0.4), vec3(0.5, 0.3, 0.2),
vec3(1.0, 0.8, 0.6), vec3(0.0, 0.05, 0.15));
gl_FragColor = vec4(color, 1.0);
}
The noise field drives the palette. High noise values get one end of the color range, low values get the other. The * 1.5 controls how much of the palette cycle we use -- multiply by more and the colors repeat faster. The + 0.5 offsets where in the palette we start. Two scalar tweaks completely change the color distribution of your scene.
We used a similar approach in episode 35 with the domain-warped noise creative exercise. That was our first taste of cosine palettes on the GPU. Now you know exactly how the function works and how to build your own presets.
Want that 8-bit, poster-art, limited-palette look? Quantize. Reduce continuous color values to a fixed number of levels:
vec3 quantize(vec3 color, float levels) {
return floor(color * levels + 0.5) / levels;
}
One line. floor(color * levels + 0.5) rounds each channel to the nearest integer multiple of 1/levels. Divide by levels to get back to the 0-1 range. With levels = 4.0, you get 4 distinct values per channel (64 total colors). With levels = 2.0, you get 8 colors total -- pure binary art.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
vec3 quantize(vec3 color, float levels) {
return floor(color * levels + 0.5) / levels;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float dist = length(uv);
// smooth gradient using palette
float t = dist * 2.0 + u_time * 0.1;
vec3 smooth = palette(t,
vec3(0.5,0.5,0.5), vec3(0.5,0.5,0.5),
vec3(1.0,1.0,1.0), vec3(0.0,0.33,0.67));
// quantize to 5 levels per channel
vec3 color = quantize(smooth, 5.0);
gl_FragColor = vec4(color, 1.0);
}
A radial gradient that would normally be silky smooth, reduced to visible color bands. It looks like a screen print or a risograph -- that graphic design aesthetic that's been trendy for the last decade. Increase levels for more subtle banding, decrease for more aggressive posterization.
The visual weight of quantization depends on the underlying gradient. Slow gradients (big areas of similar color) show the bands clearly. Fast gradients (rapid color change) barely show the quantization because adjacent pixels already differ. So if you want very visible banding, use it on smooth, slow-moving fields.
Heavy quantization creates visible sharp edges between color bands. Dithering smooths those edges by adding a tiny amount of noise before quantizing. The noise pushes borderline pixels randomly into one band or the other, breaking up the hard edge into a speckled transition:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
vec3 quantize(vec3 color, float levels) {
return floor(color * levels + 0.5) / levels;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
float dist = length(uv);
float t = dist * 2.0 + u_time * 0.1;
vec3 smooth = palette(t,
vec3(0.5,0.5,0.5), vec3(0.5,0.5,0.5),
vec3(1.0,1.0,1.0), vec3(0.0,0.33,0.67));
// add noise before quantizing (dithering)
float noise = (hash(gl_FragCoord.xy) - 0.5) / 5.0;
vec3 dithered = quantize(smooth + noise, 5.0);
gl_FragColor = vec4(dithered, 1.0);
}
Compare this to the previous example. Same palette, same quantization, but the band edges are now speckled instead of razor-sharp. The noise / 5.0 matches the quantization levels -- one step is 1/5 = 0.2 wide, so noise in the range +/- 0.1 is enough to blur the boundary without affecting the center of each band.
This is the same principle behind ordered dithering in old game hardware. The Game Boy, the NES, early Mac -- they all used dithering patterns to simulate more colors than the hardware could actually display. We're doing the same thing, just with random noise instead of an ordered pattern. You can use an ordered Bayer matrix for more structured dithering, but for most shader art the random approach looks better because it doesn't introduce visible grid artifacts.
Photoshop's blend modes -- multiply, screen, overlay, soft light -- are just math operations on two color layers. In a shader, each blend mode is a single function:
vec3 blendMultiply(vec3 base, vec3 blend) {
return base * blend;
}
vec3 blendScreen(vec3 base, vec3 blend) {
return 1.0 - (1.0 - base) * (1.0 - blend);
}
vec3 blendOverlay(vec3 base, vec3 blend) {
return mix(
2.0 * base * blend,
1.0 - 2.0 * (1.0 - base) * (1.0 - blend),
step(0.5, base)
);
}
vec3 blendSoftLight(vec3 base, vec3 blend) {
return mix(
2.0 * base * blend + base * base * (1.0 - 2.0 * blend),
sqrt(base) * (2.0 * blend - 1.0) + 2.0 * base * (1.0 - blend),
step(0.5, blend)
);
}
Multiply darkens: base * blend. If either layer is dark, the result is dark. Both bright = bright. It's like stacking two transparencies on a light table.
Screen brightens: the inverse of multiply applied to inverted colors. If either layer is bright, the result is bright. It's like projecting two slides onto the same wall.
Overlay is a combination: multiply in the dark areas, screen in the bright areas. It increases contrast while preserving the base layer's overall brightness distribution.
Soft light is a gentler version of overlay. Less extreme contrast boost, smoother transitions between the dark and bright ranges.
Here's a practical example -- blend a stripe pattern with a radial gradient using different modes:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 blendMultiply(vec3 base, vec3 blend) {
return base * blend;
}
vec3 blendScreen(vec3 base, vec3 blend) {
return 1.0 - (1.0 - base) * (1.0 - blend);
}
vec3 blendOverlay(vec3 base, vec3 blend) {
return mix(
2.0 * base * blend,
1.0 - 2.0 * (1.0 - base) * (1.0 - blend),
step(0.5, base)
);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// base layer: warm radial gradient
float dist = length(uv);
vec3 base = mix(vec3(0.9, 0.5, 0.2), vec3(0.1, 0.1, 0.3), dist * 2.0);
// blend layer: vertical stripes
float stripe = sin(uv.x * 40.0 + u_time) * 0.5 + 0.5;
vec3 blend = vec3(stripe);
// pick blend mode based on horizontal position
vec3 color;
if (uv.x < -0.2) {
color = blendMultiply(base, blend);
} else if (uv.x < 0.2) {
color = blendOverlay(base, blend);
} else {
color = blendScreen(base, blend);
}
gl_FragColor = vec4(color, 1.0);
}
Left third: multiply (stripes darken the gradient). Middle: overlay (stripes increase contrast). Right third: screen (stripes brighten everything). Same two layers, three completely different visual results. Blend modes are how you composite multiple visual elements in shaders without them looking flat or pasted on.
Color temperature is the warm/cool axis -- shifting from bluish (cool) to orangeish (warm). In photography this is "white balance." In shaders, it's a channel-level adjustment:
vec3 adjustTemperature(vec3 color, float temp) {
// positive temp = warmer (more orange), negative = cooler (more blue)
color.r += temp * 0.1;
color.b -= temp * 0.1;
return clamp(color, 0.0, 1.0);
}
That's the naive version -- just push red up and blue down (or vice versa). It works but it also shifts the overall brightness. A more sophisticated approach works in a perceptual color space, but for creative coding the simple version is usually fine. You're going for a vibe, not color-accurate photography.
Remember complementary and analogous colors from episode 7? In HSV, computing harmonies is just arithmetic on the hue:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// base hue rotates with time
float baseHue = fract(u_time * 0.05);
// complementary: +0.5 on the color wheel
float compHue = fract(baseHue + 0.5);
// analogous: +/- 1/12 (30 degrees)
float ana1 = fract(baseHue + 1.0/12.0);
float ana2 = fract(baseHue - 1.0/12.0);
// triadic: +1/3 and +2/3
float tri1 = fract(baseHue + 1.0/3.0);
float tri2 = fract(baseHue + 2.0/3.0);
// use distance from center to pick which harmony color
float dist = length(uv);
float angle = atan(uv.y, uv.x) / 6.28318 + 0.5;
// split into 3 sectors
float sector = floor(angle * 3.0);
float hue;
if (sector < 1.0) {
hue = baseHue;
} else if (sector < 2.0) {
hue = tri1;
} else {
hue = tri2;
}
float sat = smoothstep(0.5, 0.1, dist);
float val = smoothstep(0.55, 0.0, dist);
vec3 color = hsv2rgb(vec3(hue, sat, val));
gl_FragColor = vec4(color, 1.0);
}
Three sectors of the canvas, each showing one color of a triadic harmony. The base hue rotates slowly, and the triadic partners follow automatically -- always exactly 120 degrees apart on the color wheel. Swap the +1.0/3.0 offsets for +0.5 (complementary) or +1.0/12.0 (analogous) and you get different harmony schemes. The math is trivial. The visual impact is significant -- harmonious colors just look "right" together, and computing them programmatically means your palette stays balanced no matter what the base hue does.
Gradient mapping is a technique borrowed from image processing. You create a 1D color gradient (a "lookup table") and use a grayscale value to index into it. Dark pixels get colors from the left side of the gradient, bright pixels from the right. Photoshop has this as an adjustment layer.
In shaders, we can simulate a gradient LUT with a few mix calls:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 hash2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453) * 2.0 - 1.0;
}
float gradientNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = dot(hash2(i), f);
float b = dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
float c = dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
float d = dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
vec3 gradientMap(float t) {
// 4-stop gradient: dark blue -> teal -> orange -> bright yellow
vec3 c0 = vec3(0.05, 0.05, 0.2); // stop at 0.0
vec3 c1 = vec3(0.1, 0.5, 0.5); // stop at 0.33
vec3 c2 = vec3(0.8, 0.4, 0.1); // stop at 0.66
vec3 c3 = vec3(1.0, 0.9, 0.4); // stop at 1.0
t = clamp(t, 0.0, 1.0);
if (t < 0.33) return mix(c0, c1, t / 0.33);
if (t < 0.66) return mix(c1, c2, (t - 0.33) / 0.33);
return mix(c2, c3, (t - 0.66) / 0.34);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// grayscale input: noise
float n = gradientNoise(uv * 5.0 + u_time * 0.2) * 0.5 + 0.5;
// map through our gradient
vec3 color = gradientMap(n);
gl_FragColor = vec4(color, 1.0);
}
The gradientMap function is a manual 4-stop gradient. Dark noise values get deep blue, mid values get teal and orange, bright values get yellow. You could define any color ramp this way -- heatmap, thermal, elevation, whatever. The cosine palette is more flexible for procedural work, but gradient mapping lets you pick exact colors at exact positions, which is better when you need specific brand colors or when matching a reference image.
For production work where you need more than 4 stops or want to use an actual image as the gradient, you'd load a 1D texture (a 256x1 pixel image) and sample it with texture2D. But for most creative coding, the manual mix chain is easier to tweak.
Let's put it all together. A shader where the mouse controls the palette parameters, and a noise field shows the result in real time. Mouse X controls base hue. Mouse Y controls how much of the palette range we use. The canvas shows a domain-warped noise field rendered with the current cosine palette:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
vec2 hash2(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return fract(sin(p) * 43758.5453) * 2.0 - 1.0;
}
float gradientNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = dot(hash2(i), f);
float b = dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
float c = dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
float d = dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * gradientNoise(p);
p *= 2.0;
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec2 mouse = u_mouse / u_resolution;
// domain warp the noise
float t = u_time * 0.1;
vec2 q = vec2(fbm(uv * 3.0 + t), fbm(uv * 3.0 + vec2(5.2, 1.3) + t));
float n = fbm(uv * 3.0 + 3.0 * q);
// mouse.x shifts the palette phase (base hue)
// mouse.y controls palette amplitude (color range)
float phase = mouse.x;
float amp = mouse.y * 0.5 + 0.25;
vec3 color = palette(n * 1.5,
vec3(0.5, 0.5, 0.5),
vec3(amp, amp, amp),
vec3(1.0, 0.7, 0.4),
vec3(phase, phase + 0.1, phase + 0.2));
// subtle vignette
float vig = 1.0 - length(uv) * 0.4;
color *= vig;
gl_FragColor = vec4(color, 1.0);
}
Move the mouse around. Left-right shifts through completely different color schemes -- what was warm orange becomes cool blue, then green, then purple. Up-down controls the color range from nearly monochromatic (mouse at bottom) to full rainbow (mouse at top). The noise field keeps evolving underneath, so you get this constantly shifting landscape that you're painting with color by moving your hand.
This is my go-to workflow for finding color schemes. Set up a shader like this, wave the mouse around for a few minutes, screenshot anything that catches your eye. Then extract the palette parameters from that mouse position and hardcode them for the final piece. Faster than scrolling through palette websites, and the colors are already matched to your specific visual content.
Color space conversions are cheap. The rgb2hsv and hsv2rgb functions are a handful of arithmetic operations per pixel -- nothing compared to the noise functions we've been computing. A cosine palette is literally one cos call per channel. Quantization is one floor. You can stack all of these color operations on top of a complex noise field and the color math will be a rounding error in your frame time.
The one thing to watch is precision. When you chain multiple color operations (convert to HSV, rotate hue, adjust saturation, convert back to RGB, then blend), floating point errors can accumulate. You might get colors slightly outside the 0-1 range. Always clamp your final output to vec4(clamp(color, 0.0, 1.0), 1.0) before assigning to gl_FragColor. It's a one-line safety net that prevents weird rendering artifacts on some GPUs.
Also: the sin-based hash function used in the palette presets demo (with the if/else chain) is techncially branching, which GPUs don't love. In a real production shader you'd compute all eight palettes and blend between them based on position, avoiding the branches entirely. For learning and experimentation, the branching version is clearer. For a final piece running on a gallery installation at 4K, refactor to branchless.
Everything we've built today will show up in the rest of this series. The cosine palette function is going to be in nearly every shader from here on -- it's that useful. The HSV conversions will matter when we start doing post-processing effects and need to selectively adjust parts of an image. The quantization and dithering techniques come back when we look at retro aesthetics and pixel art generators.
And when we combine color manipulation with the feedback loops from last episode -- imagine trails that shift hue as they decay, or edge detection where the edge color encodes the edge direction. Color isn't just decoration. In shaders, color is data. A pixel's color can represent chemical concentrations, flow velocity, temperature, distance, age, or anything else you can encode as three floating point numbers. The ability to manipulate those channels precisely is what separates a nice-looking shader from one that does something genuinely interesting.
Next up we're stepping into actual 3D -- building entire worlds from nothing but math, without a single polygon. The same distance function concepts from episode 33, but in three dimensions, rendered through a technique called raymarching. It's going to be wild :-)
rgb2hsv / hsv2rgb conversion functions that the shader community has standardized onhsv.x = fract(hsv.x + offset). In RGB the same operation would need a 3x3 matrixa + b * cos(6.28318 * (c * t + d)) -- four vec3 parameters, infinite smooth color ramps from a single float inputfloor(color * levels + 0.5) / levels reduces continuous color to discrete bands for retro/poster aestheticsa*b), screen (1-(1-a)*(1-b)), overlay (multiply in darks, screen in brights)Sallukes! Thanks for reading.
X