Picking colours in a fragment shader can feel deceptively simple – until everything turns muddy, noisy, or visually inconsistent. This guide is for shader learners and creative coders who want practical palette techniques that keep output readable, stylised, and GIF-friendly without relying on trial-and-error.
What a โpaletteโ really does in shader art
A palette is a set of constraints. Instead of letting every pixel wander anywhere in RGB space, you define a controlled path through colour so your shader stays coherent as values change across space, time, or lighting. In practice, palettes help you:
- Maintain contrast – avoid mid-tone soup and keep focal areas clear.
- Control mood – warm/cool balance, saturation limits, and accent colours.
- Reduce visual noise – especially when exporting to GIF (limited colours and compression artefacts).
- Make iteration faster – tweak a few parameters rather than repainting the whole colour logic.
If you are new to basic colour handling in fragment shaders, it helps to understand how RGB values, interpolation, and gamma interact. The ShaderGif docs on shader basics and common uniforms are a good foundation before you start building complex palette functions.
Start with one driving value, not three colours
Most palette approaches become much easier when you define a single scalar โdriverโ (often t in the range 0..1) and map it to colour. Common drivers:
- Distance fields (e.g. radius, signed distance, edge thickness)
- Noise (Perlin/simplex/value noise)
- Lighting terms (NยทL, fresnel, ambient occlusion approximations)
- Time (animated gradients, cycling accents)
Once you have a stable driver, you can clamp it, curve it, and reuse it across multiple materials so the whole scene feels related.
Cosine palettes (fast, smooth, and tweakable)
A popular method for shader palettes is the cosine palette: a smooth, periodic mapping from a scalar to RGB. It is ideal for gradients, animated colour cycling, and โpaintedโ looks because the output is naturally smooth. A widely used reference is Iรฑigo Quรญlezโs palette write-up – see palette functions for procedural colour.
// Cosine palette: returns RGB in 0..1 from a scalar t.
// a - base colour (offset)
// b - amplitude (how strong variation is)
// c - frequency (how many cycles across t)
// d - phase shift (moves colours along the gradient)
vec3 cosinePalette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.2831853 * (c * t + d));
}
// Example: a calm โoceanโ style palette
vec3 oceanPalette(float t) {
vec3 a = vec3(0.20, 0.35, 0.45);
vec3 b = vec3(0.20, 0.25, 0.30);
vec3 c = vec3(1.00, 1.00, 1.00);
vec3 d = vec3(0.00, 0.10, 0.20);
return cosinePalette(t, a, b, c, d);
}
How to use it: build a driver t (distance, noise, lighting), optionally remap it (with smoothstep or curves), then call your palette function. This keeps your shader readable and makes it easy to swap looks by swapping parameters rather than rewriting logic.
Remap your driver to avoid banding and mud
Two quick improvements:
- Clamp and soften edges: use
smoothstepto avoid harsh transitions unless you want posterisation. - Curve contrast: apply a power curve (
pow(t, k)) or an S-curve to push values towards highlights/shadows.
float remap01(float x) { return clamp(x, 0.0, 1.0); }
float contrastCurve(float t, float k) {
t = remap01(t);
// k > 1 pushes towards darker midtones, k < 1 lifts them
return pow(t, k);
}
float softBand(float t, float edge0, float edge1) {
return smoothstep(edge0, edge1, t);
}
Gradient maps (palettes as 1D textures)
Another robust approach is a gradient map: store a palette as a 1D texture and sample it with your driver. This is great when you want art-directed palettes or multiple โstopsโ that are hard to express with a simple function.
Even if your environment does not use a dedicated 1D texture, you can treat a small 2D texture as a strip and sample along X. For example, if paletteTex is a horizontal gradient image, use vec2(t, 0.5) as UV.
uniform sampler2D paletteTex;
vec3 samplePalette(float t) {
t = clamp(t, 0.0, 1.0);
return texture(paletteTex, vec2(t, 0.5)).rgb;
}
This technique pairs well with a โmaterialsโ mindset: keep your shapes and lighting separate, then apply different palette textures to different regions (or crossfade between them).
Work in linear space (so blending behaves)
Many muddy results come from blending colours in the wrong space. Most displays and image assets are in sRGB (gamma encoded). If you mix colours or accumulate light in sRGB, gradients can look dull and midtones can collapse.
A simple practice: convert to linear for maths, then convert back for output. If you are rendering to a WebGL canvas, this still helps because your shader logic becomes predictable even if your final pipeline is minimal.
// Approximate sRGB <-> linear conversions.
// Good enough for many shader-art use cases.
vec3 toLinear(vec3 c) { return pow(c, vec3(2.2)); }
vec3 toSRGB(vec3 c) { return pow(c, vec3(1.0 / 2.2)); }
Tip: do your palette mapping in linear if you are doing lighting or additive glow, then convert to sRGB at the end. If you are doing flat graphic looks, you can sometimes stay in sRGB intentionally – just be consistent.
Hue control with HSV/HSL (use sparingly, but useful)
HSV/HSL conversions are handy when you want to โspin hueโ while keeping brightness consistent, or when you want to clamp saturation so highlights do not turn neon. They can be more expensive than simple RGB maths, so treat them as a tool for targeted control rather than the default for every pixel.
// Compact HSV->RGB (common shader snippet)
vec3 hsv2rgb(vec3 c) {
vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0);
vec3 rgb = clamp(p - 1.0, 0.0, 1.0);
return c.z * mix(vec3(1.0), rgb, c.y);
}
// Example: animated hue sweep with clamped saturation/value
vec3 hueSweep(float t) {
float hue = fract(t);
float sat = 0.65;
float val = 0.95;
return hsv2rgb(vec3(hue, sat, val));
}
Practical use: generate a base palette with cosine, then apply a mild hue shift for accents only (edges, glows, sparks), rather than shifting everything.
Make palettes loop cleanly (for seamless GIFs)
If your animation loops, your colours must also return to the start state. With cosine palettes this is naturally easy because they are periodic. For time-based palettes:
- Use a normalised loop time
lt = fract(time / loopSeconds). - Drive hue or palette index from
ltrather than rawtime. - If you use noise, ensure the noise is loopable (or sample noise on a circle).
float loopTime(float time, float seconds) {
return fract(time / seconds);
}
vec3 loopingAccent(float time) {
float lt = loopTime(time, 4.0); // 4 second loop
// Cosine palettes loop nicely across 0..1
return oceanPalette(lt);
}
If you are exporting and your loop still โpopsโ at the seam, check if the pop is coming from camera motion, UV warping, or temporal noise rather than colour. The guide on exporting WebGL to GIF is useful when you need the whole pipeline to loop smoothly.
Palettes that survive GIF export
GIF is unforgiving: limited colours, dithering, and compression can turn smooth gradients into banding. Palette design can reduce artefacts dramatically:
- Prefer fewer distinct hues for large flat areas (GIF loves simple regions).
- Keep gradients short – push contrast with value, not infinite rainbow ramps.
- Add subtle noise before quantisation to hide banding (but keep it low).
- Avoid extreme dark + saturated mixes – they compress poorly and shimmer.
// Tiny dithering noise to reduce banding in gradients.
// Use a stable hash from pixel coords so it does not flicker.
float hash12(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec3 applyDither(vec3 col, vec2 fragCoord, float strength) {
float n = hash12(fragCoord) - 0.5;
return col + n * strength;
}
When you are balancing quality vs speed, remember that shader output is only half the story – capture settings matter too. If you see black output or unexpected colour issues, the troubleshooting guide for black canvas problems can save time.
Five ready-to-use palette recipes
Use these as starting points. Each takes a scalar t in 0..1. Swap drivers and remaps until the result matches your scene.
1) Sunset gradient (warm highlights, cool shadows)
vec3 sunsetPalette(float t) {
t = clamp(t, 0.0, 1.0);
// Push midtones brighter for a painterly look
t = mix(t, smoothstep(0.0, 1.0, t), 0.6);
vec3 a = vec3(0.55, 0.25, 0.20);
vec3 b = vec3(0.35, 0.25, 0.20);
vec3 c = vec3(1.00, 1.00, 1.00);
vec3 d = vec3(0.00, 0.10, 0.20);
return cosinePalette(t, a, b, c, d);
}
2) Neon (high saturation accents, dark base)
vec3 neonPalette(float t) {
t = clamp(t, 0.0, 1.0);
vec3 a = vec3(0.05, 0.05, 0.08);
vec3 b = vec3(0.65, 0.35, 0.75);
vec3 c = vec3(1.00, 1.00, 1.00);
vec3 d = vec3(0.10, 0.20, 0.30);
return cosinePalette(t, a, b, c, d);
}
3) Ocean (soft teal range)
vec3 oceanSoft(float t) {
t = contrastCurve(t, 1.2);
return oceanPalette(t);
}
4) Monochrome with tint (clean value control)
vec3 monoTint(float t, vec3 tint) {
t = clamp(t, 0.0, 1.0);
vec3 base = vec3(t);
return mix(base, base * tint, 0.6);
}
5) Heatmap style (useful for debugging fields)
vec3 heatmap(float t) {
t = clamp(t, 0.0, 1.0);
// Simple segmented ramp
vec3 c1 = vec3(0.10, 0.10, 0.35);
vec3 c2 = vec3(0.10, 0.50, 0.70);
vec3 c3 = vec3(0.90, 0.80, 0.20);
vec3 c4 = vec3(0.90, 0.20, 0.10);
if (t < 0.33) return mix(c1, c2, t / 0.33);
if (t < 0.66) return mix(c2, c3, (t - 0.33) / 0.33);
return mix(c3, c4, (t - 0.66) / 0.34);
}
Once you have a palette you like, reuse it everywhere: background, highlights, fog, and post effects. Consistency is often what makes shader pieces feel โdesignedโ rather than random.
Common palette mistakes (and quick fixes)
- Everything is mid-tone: remap your driver – clamp, then push contrast with
poworsmoothstep. - Highlights look grey: you may be blending in sRGB – try linear maths for lighting or add a controlled accent colour.
- Banding in gradients: shorten gradients, add tiny stable dithering, or use a palette texture with more deliberate stops.
- Too many colours for GIF: reduce hue range, prefer value changes, and keep large regions relatively flat.
Internal link suggestions
- Shader basics – explain fragment shaders, UVs, and colour output fundamentals.
- Common uniforms – document time, resolution/ratio, and coordinate patterns used across examples.
- GLSL tips and snippets – a grab-bag of reusable helpers (hashes, noise, remaps, SDF utilities).
- Anti-aliasing basics – show how smooth edges and gradients affect perceived colour quality.
- Export WebGL to GIF – connect palette choices to capture settings, quantisation, and loop quality.
