colors again

Continues shapes.

In this episode we finally give our shape some color.

And color is a good excuse to meet a new function.

mix(colorA, colorB, amt)

mix() is linear interpolation between two values — the same operation you'll see called lerp elsewhere.

Think of it as walking from colorA to colorB, with amt saying how far along you are: 0.0 is the start, so you get colorA; 1.0 is the end, so you get colorB; 0.5 lands exactly halfway.

First, two colors to travel between:

shader.frag · glsl file
uniform float uTime;

+ vec3 colorA = vec3(1.0, 0 , 0); //red
+ vec3 colorB = vec3(0, 0, 1.0); //blue
+

Then we mix them and use the result instead of the grey d:

shader.frag · glsl file
  void main(){
  vec2 uv=(gl_FragCoord.xy * 2.0 - uResolution.xy) / uResolution.y; // -1 to 1

+  float amt = 0.5;
+  vec3 color = mix(colorA, colorB, amt);
+
shader.frag · glsl file · update
float d = heart(uv);
  d = sin( d * 3.14 * 4.0);
  d = abs(d);
  d = 0.2/d;

- fragColor = vec4(d, d, d, 1.0);
+ fragColor = vec4(color, 1.0);

A single flat purple — halfway between red and blue. Feeding amt a coordinate instead of a constant turns it into a gradient:

shader.frag · glsl file · update
- float amt = 0.5;
+ float amt = uv.x;

uv.x runs from -1 to 1, and mix() wants 0 to 1, so the whole left half clamps. Remap it:

shader.frag · glsl file · update
- float amt = uv.x;
+ float amt = (uv.x + 1.0) / 2.0;

We can't hand gl_FragCoord.xy to mix() directly, because it's measured in pixels — 0 to 400 on a wide canvas — and everything past 1.0 clamps to the end color.

To steer each channel on its own, make amt a vec3:

shader.frag · glsl file · update
- float amt = (uv.x + 1.0) / 2.0;
+ vec3 amt = vec3((uv.x + 1.0) / 2.0);

Now that it's a vector we can pin one channel and leave the others fading:

shader.frag · glsl file · variant
vec3 amt = vec3((uv.x + 1.0) / 2.0); 
+ amt.r = 0.5;

And we can reach for step() from the last article:

shader.frag · glsl file · variant
vec3 amt = vec3((uv.x + 1.0) / 2.0);
+ amt.r = step(0.2, amt.r);

The red channel now jumps rather than ramps: left of the threshold it takes all of colorA's red, right of it none. Green and blue keep gradient — so we get a hard vertical cut running through a smooth fade.


amt doesn't have to be a coordinate. Any distance function works — starting with length:

shader.frag · glsl file · variant
- vec3 amt = vec3((uv.x + 1.0) / 2.0);
+ float amt = length(uv);

Or the heart:

shader.frag · glsl file · variant
- vec3 amt = vec3((uv.x + 1.0) / 2.0);
+ float amt = heart(uv);

The distance is signed: negative inside the shape, zero on the edge, positive outside. So step(0.0, …) shows us exactly where that sign flips:

shader.frag · glsl file · variant
- vec3 amt = vec3((uv.x + 1.0) / 2.0);
+ float amt = step(0.0, heart(uv));

The silhouette comes out smaller than the glow suggested — the rings extend well past the edge itself.

Or we can fade between two limits:

shader.frag · glsl file · variant
- vec3 amt = vec3((uv.x + 1.0) / 2.0);
+ float amt = smoothstep(0.0, 0.5, heart(uv));

Now let's bring back the grid of hearts from the previous article.

glsl
float d = heart(uv);        // signed distance to the heart: negative inside, 0 on the edge
d = sin( d * 3.14 * 4.0);   // bend that distance into a wave — a zero crossing every 0.25 units
d = abs(d);                 // fold the troughs up, so every crossing becomes a dark ring

0.2 / d then turns those dark rings into bright filaments — small distances explode, large ones fade to black.

shader.frag · glsl file · variant
float d = heart(uv);
  d = sin( d * 3.14 * 4.0);
  d = abs(d);
  d = 0.2/d;
  
- fragColor = vec4(color, 1.0);
+ fragColor = vec4(vec3(d), 1.0);

d is the brightness and color is the hue, so multiplying them gives us colored light:

shader.frag · glsl file · update
- fragColor = vec4(color, 1.0);
+ fragColor = vec4(color * d, 1.0);

color formula

So far every gradient has been a straight line from colorA to colorB.

There's a richer formula: color(t) = a + b * cos(2 * 3.14 * (c * t + d)). Because each channel gets its own cosine, we sweep through a whole spectrum instead of two endpoints.

a = vec3() is the Brightness of each chanle
b = vec3() is the Contrast
c = vec3() is Frequency of the change over time
d = vec3() is the Phase

So we drop mix and write a palette function instead:

shader.frag · glsl file
+ vec3 palette(float t) {
+   vec3 a = vec3(0.3, 0.3, 0.4);
+   vec3 b = vec3(0.4, 0.4, 0.1);
+   vec3 c = vec3(0.4, .4, 1.0);
+   vec3 d = vec3(0.00, 0.33, 0.67);
+   return a + b * cos(6.28318 * (c * t + d));
+ }
+ 
void main(){
shader.frag · glsl file · variant
- vec3 amt = vec3((uv.x + 1.0) / 2.0); 
- vec3 color = mix(colorA, colorB, amt);
+  vec3 color = palette(length(uv));

Much richer already.

Now let's feed it the heart distance instead — and drop the grid modifier for a moment so the shape stays readable.

shader.frag · glsl file · update
- vec3 amt = vec3((uv.x + 1.0) / 2.0); 
- vec3 color = mix(colorA, colorB, amt);
+  vec3 color = palette(heart(uv));
...
- fragColor = vec4(color * d, 1.0);
+ fragColor = vec4(color, 1.0);

Then add time, so the palette scrolls through the shape:

shader.frag · glsl file · update
- vec3 color = palette(heart(uv));
+ vec3 color = palette(heart(uv) + uTime * .3);

And bring the grid back:

shader.frag · glsl file · update
- fragColor = vec4(color, 1.0);
+ fragColor = vec4(color * d, 1.0);

fractal

On the CPU you'd build something like this with recursion. The GPU has no recursion, so we do the next best thing: run the same remap in a for loop.

shader.frag · glsl file · update
void main(){
  vec2 uv=(gl_FragCoord.xy * 2.0 - uResolution.xy) / uResolution.y; // -1 to 1

+   for (float i=0.0; i < 2.0; i++){
        ...
        fragColor = vec4(color * d, 1.0);
+   }
}

Why does this change anything? Because uv is modified in place: uv = fract(uv * 2.0) - 0.5 runs again on the result of the previous pass, so each iteration subdivides the grid the last one produced. That's the recursion, unrolled. But fragColor is assigned every pass, so only the last layer survives — the earlier ones are overwritten.

The fix is to accumulate instead of assign:

shader.frag · glsl file · update
+ vec3 finalColor = vec3(0.0, 0.0, 0.0);
void main(){
  vec2 uv=(gl_FragCoord.xy * 2.0 - uResolution.xy) / uResolution.y; // -1 to 1
    ...

-   fragColor = vec4(color * d, 1.0);
+    finalColor += color * d;
   }
+     fragColor = vec4(finalColor, 1.0);  
}

Now every layer adds its light on top of the one before it — and since light adds, the overlaps get brighter on their own.