RGB vs OKLab ramp
The same two colors mixed three ways, stacked so you can see why ramps mixed in RGB go muddy or wash out.
- 01GoalColor
- 02TechniqueGradients
- 03ExecutionRGB vs OKLab ramp
Parameters
Copy for your platform
- GLSLCopy
- GodotCopy
Node chain
Built step by step, in GLSL style, with its node names.
Built step by step, in Godot style, with its node names.
No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.
No Unity version of this card yet, so the nodes show the GLSL names.
No Blender version of this card yet, so the nodes show the GLSL names.
i.uv.xUV + VectorDecomposei.uv.xi.uv.xi.uv.x UV01 Position across
mixColorFunc + Mix + ColorFuncmixmixmix In02 Mix the sRGB values
mixMixmixmixmix In03 Mix linear light
mixExpressionmixmixmix In04 Mix in OKLab
?:Compare + Switch?:?:?: In05 Stack the three bands
toOklab().xExpressiontoOklab().xtoOklab().xtoOklab().x In06 Perceived lightness
How it works
Three ramps share the same two endpoints, blue on the left and yellow on the right. Top: the sRGBThe standard encoding of screen colors. Stored values follow a curve close to a 1/2.2 power of the light, so they must be decoded before lighting or blending. sRGB The standard encoding of screen colors. Stored values follow a curve close to a 1/2.2 power of the light, so they must be decoded before lighting or blending. OKLab A color space where equal distances look like equal differences: L is lightness, a runs green to red and b blue to yellow. Ramps mixed in it stay even.linear = ((c + 0.055) / 1.055)^2.4 for c > 0.04045mix runs changes.
- Position.
t = i.uv.xruns from 0 at the left edge to 1 at the right; every band mixes by it. - Mix the sRGB values. The params arrive linear, so to show the naive mix the shader encodes both colors to sRGB, mixes the encoded numbers and decodes the result again. Blue and yellow are near opposites, so halfway the three channels nearly cancel into a flat gray, and the band looks dark and dirty there: the muddy middle.
- Mix linear light.
mix(p_left, p_right, t)on the linear values is what two overlapping lights do, so it is physically right. But the eye doesn’t respond to light linearly: a little light added to a dark color reads as a big change. The ramp brightens too fast, and its middle is a washed-out lilac. - Mix in OKLab.
toOklab()turns each color into lightness L and two opponent axes, a (green to red) and b (blue to yellow), chosen so equal distances look like equal differences. The mix runs there, andfromOklab()comes back to linear RGB. L now changes at a steady rate, and the middle is a clean light blue-gray. A straight line between two sRGB colors in OKLab can poke slightly outside sRGB’s gamutThe set of colors a color space or a screen can show. A color outside the sRGB gamut has a channel below 0 or above 1 and must be clamped or mapped back in.FundamentalGamut
The set of colors a color space or a screen can show. A color outside the sRGB gamut has a channel below 0 or above 1 and must be clamped or mapped back in.
Sources, so the result is clamped.- Gamut (opens in a new tab)Reference
- CSS Color Module Level 4 (opens in a new tab)Reference
- Stack the bands. A pair of comparisons on
i.uv.ypicks which mix each third of the quad shows, and a thin dark seam separates them. - Perceived lightness.
toOklab(ramp).xmeasures how bright each pixel looks. With “Show lightness only” on, the shader draws a gray of that same lightness (a gray’s OKLab L is the cube root of its linear value, so the gray is L cubed). The bottom band becomes an even ramp from end to end; a quarter of the way along, it has covered exactly a quarter of the lightness difference, while the sRGB band has covered about an eighth and the linear band more than a third.
No space wins everywhere. Linear is right for light: glows, fog, blur and blending. OKLab is right for color scales, where each step should look like the same amount of change. And opposite hues pass near gray in any space with a straight line; to keep the color saturated, mix the hue angle instead, in OKLCH.
The comparison computes all three ramps and two extra conversions for every pixel, which is why its count is high.
A real ramp converts its two endpoints once, outside the shader or as constants, and does only the mix, fromOklab()
and the clamp per pixel.
Make it in GLSL
The copied code is a complete GLSL ES 3.00 fragment shader for WebGL2. Draw it on a quad whose vertex shader passes
v_uv (and v_position, v_normal, v_view, which this card doesn’t read), upload p_left and p_right as linear
vec3 and p_showLightness as a bool, and draw. main() clamps and encodes the result to sRGB.
For a ramp of your own you need only two helpers, four matrices and one line. Convert the two endpoints once, on the CPU or as constants, then per pixel:
vec3 color = clamp(fromOklab(mix(labA, labB, t)), 0.0, 1.0);
Keep toOklab(), fromOklab() and the four matrices; drop srgbEncode(), srgbDecode(), the band selection and the
lightness view, which exist only for the comparison. For a multi-stop ramp, store each stop in OKLab and mix between
the two stops around t.
Make it in Godot
The shader assumes HDR 2D is off (the project default), so it decodes the color uniforms and encodes the result itself. With Rendering > Viewport > HDR 2D on, make u_toLinear() and u_toSrgb() return c unchanged.
The export is a canvas_item shader.
- Add a ColorRect sized for the comparison (it reads best wider than tall).
- In Material, create a ShaderMaterial with a new Shader and paste the code.
- Under Shader Parameters, set the left and right colors and toggle Show Lightness.
Godot’s mat3() takes three vec3 columns, as GLSL does, so the matrices and the v * M products carry over
unchanged; unlike GLSL, it rejects nine loose floats, which is why the matrices are written as vec3s. For a production
ramp you often don’t need the shader: a Gradient resource whose interpolation color space is set to OKLab, baked into a
GradientTexture1D and sampled by t, gives the bottom band for one texture read.
No Unreal Engine 5 tutorial for this card yet.
No Unity tutorial for this card yet.
No Blender tutorial for this card yet.
Code
#version 300 es// Gradients → RGB vs OKLab ramp, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform vec3 p_left; // Left color: vec3(0.1, 0.25, 0.95), sRGB; upload it decoded to linearuniform vec3 p_right; // Right color: vec3(1.0, 0.85, 0.1), sRGB; upload it decoded to linearuniform bool p_showLightness; // Show lightness only: falsein vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// OKLab's matrices, from Björn Ottosson's definition (linear sRGB in). Each vec3 is one row of his matrix, but mat3()// takes columns, so the vector multiplies from the left. Godot accepts only vec3 columns here, not nine floats.const mat3 RGB_TO_LMS = mat3( vec3(0.4122214708, 0.5363325363, 0.0514459929), vec3(0.2119034982, 0.6806995451, 0.1073969566), vec3(0.0883024619, 0.2817188376, 0.6299787005));const mat3 LMS_TO_LAB = mat3( vec3(0.2104542553, 0.7936177850, -0.0040720468), vec3(1.9779984951, -2.4285922050, 0.4505937099), vec3(0.0259040371, 0.7827717662, -0.8086757660));const mat3 LAB_TO_LMS = mat3( vec3(1.0, 0.3963377774, 0.2158037573), vec3(1.0, -0.1055613458, -0.0638541728), vec3(1.0, -0.0894841775, -1.2914855480));const mat3 LMS_TO_RGB = mat3( vec3(4.0767416621, -3.3077115913, 0.2309699292), vec3(-1.2684380046, 2.6097574011, -0.3413193965), vec3(-0.0041960863, -0.7034186147, 1.7076147010));// The input is a color between 0 and 1, so its cone responses are never negative and pow() needs no sign fixvec3 toOklab(vec3 rgb) { return pow(rgb * RGB_TO_LMS, vec3(1.0 / 3.0)) * LMS_TO_LAB;}vec3 fromOklab(vec3 lab) { vec3 lms = lab * LAB_TO_LMS; return (lms * lms * lms) * LMS_TO_RGB;}vec3 srgbEncode(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}vec3 srgbDecode(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec4 effect(Inputs i) { float t = i.uv.x; // What mixing two hex codes does: blend the encoded numbers, then decode, since everything here is linear vec3 encodedMix = srgbDecode(mix(srgbEncode(p_left), srgbEncode(p_right), t)); vec3 linearMix = mix(p_left, p_right, t); // sRGB's gamut isn't convex in OKLab, so a straight line between two of its colors can poke slightly outside it vec3 oklabMix = clamp(fromOklab(mix(toOklab(p_left), toOklab(p_right), t)), 0.0, 1.0); vec3 ramp = i.uv.y > 2.0 / 3.0 ? encodedMix : (i.uv.y > 1.0 / 3.0 ? linearMix : oklabMix); // A gray has OKLab lightness L when its linear value is L cubed, so this gray looks as bright as the ramp does float lightness = toOklab(ramp).x; vec3 shown = p_showLightness ? vec3(lightness * lightness * lightness) : ramp; float seam = min(abs(i.uv.y - 1.0 / 3.0), abs(i.uv.y - 2.0 / 3.0)); return vec4(shown * step(0.006, seam), 1.0);}void main() { Inputs i; i.uv = v_uv; i.position = v_position; // Interpolation shortens the vectors between vertices i.normal = normalize(v_normal); i.view = normalize(v_view); i.time = u_time; i.fragCoord = gl_FragCoord.xy; i.resolution = u_resolution; vec4 frag_out = effect(i); // The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1 fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}// Gradients → RGB vs OKLab ramp, Godot 4. From Creative Center, MIT license.shader_type canvas_item;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform vec3 left : source_color = vec3(0.1, 0.25, 0.95);uniform vec3 right : source_color = vec3(1.0, 0.85, 0.1);uniform bool showLightness = false;struct Inputs { vec2 uv; vec3 position; vec3 normal; vec3 view; float time; vec2 fragCoord; vec2 resolution;};// Godot's 2D renderer works in sRGB (HDR 2D off, the default), so the frame decodes color params and sRGB// textures to linear itself and encodes the result. With HDR 2D on, make these return c unchanged.vec3 u_toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec3 u_toSrgb(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// OKLab's matrices, from Björn Ottosson's definition (linear sRGB in). Each vec3 is one row of his matrix, but mat3()// takes columns, so the vector multiplies from the left. Godot accepts only vec3 columns here, not nine floats.const mat3 RGB_TO_LMS = mat3( vec3(0.4122214708, 0.5363325363, 0.0514459929), vec3(0.2119034982, 0.6806995451, 0.1073969566), vec3(0.0883024619, 0.2817188376, 0.6299787005));const mat3 LMS_TO_LAB = mat3( vec3(0.2104542553, 0.7936177850, -0.0040720468), vec3(1.9779984951, -2.4285922050, 0.4505937099), vec3(0.0259040371, 0.7827717662, -0.8086757660));const mat3 LAB_TO_LMS = mat3( vec3(1.0, 0.3963377774, 0.2158037573), vec3(1.0, -0.1055613458, -0.0638541728), vec3(1.0, -0.0894841775, -1.2914855480));const mat3 LMS_TO_RGB = mat3( vec3(4.0767416621, -3.3077115913, 0.2309699292), vec3(-1.2684380046, 2.6097574011, -0.3413193965), vec3(-0.0041960863, -0.7034186147, 1.7076147010));// The input is a color between 0 and 1, so its cone responses are never negative and pow() needs no sign fixvec3 toOklab(vec3 rgb) { return pow(rgb * RGB_TO_LMS, vec3(1.0 / 3.0)) * LMS_TO_LAB;}vec3 fromOklab(vec3 lab) { vec3 lms = lab * LAB_TO_LMS; return (lms * lms * lms) * LMS_TO_RGB;}vec3 srgbEncode(vec3 c) { return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}vec3 srgbDecode(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));}vec4 effect(Inputs i) { float t = i.uv.x; // What mixing two hex codes does: blend the encoded numbers, then decode, since everything here is linear vec3 encodedMix = srgbDecode(mix(srgbEncode(u_toLinear(left)), srgbEncode(u_toLinear(right)), t)); vec3 linearMix = mix(u_toLinear(left), u_toLinear(right), t); // sRGB's gamut isn't convex in OKLab, so a straight line between two of its colors can poke slightly outside it vec3 oklabMix = clamp(fromOklab(mix(toOklab(u_toLinear(left)), toOklab(u_toLinear(right)), t)), 0.0, 1.0); vec3 ramp = i.uv.y > 2.0 / 3.0 ? encodedMix : (i.uv.y > 1.0 / 3.0 ? linearMix : oklabMix); // A gray has OKLab lightness L when its linear value is L cubed, so this gray looks as bright as the ramp does float lightness = toOklab(ramp).x; vec3 shown = showLightness ? vec3(lightness * lightness * lightness) : ramp; float seam = min(abs(i.uv.y - 1.0 / 3.0), abs(i.uv.y - 2.0 / 3.0)); return vec4(shown * step(0.006, seam), 1.0);}void fragment() { Inputs i; // UV starts at the top-left; the effect expects GLSL's bottom-left i.uv = vec2(UV.x, 1.0 - UV.y); // A flat quad facing the viewer, the same as the site's 2D preview i.position = vec3(i.uv * 2.0 - 1.0, 0.0); i.normal = vec3(0.0, 0.0, 1.0); i.view = vec3(0.0, 0.0, 1.0); i.time = TIME; // FRAGCOORD's origin is the top-left too i.resolution = 1.0 / SCREEN_PIXEL_SIZE; i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y); vec4 frag_out = effect(i); COLOR = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}No Unreal Engine 5 code for this card yet.
No Unity code for this card yet.
No Blender code for this card yet.