Baroque divide lines come alive

February 22, 2020

On twitter I suggested that my future blog posts would be sprinkled with animated shaders in the space between paragraphs.

Some people (quite reasonbly) though this was a joke. But I actually had in mind a modern take on the ornate section divide lines you might find in an old book.

void main() { vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5)); float t = time*.9; float m = .001; m += clamp(.2*sin(acos(1.6*pow(abs(a.x-1.1),0.4))),0.,1.); m += .5*clamp(.4*sin(acos(2.2*pow(abs(a.x),1.))),0.,1.); m += clamp(.1*sin(10.*a.x+t),0.,1.); m *= clamp(1.0-.25*a.x,.001,1.); float thick = .1+.09*sin(7.*a.x+.3*t)*sin(5.*sin(2.*a.x+.2*t)+t); thick *= clamp(1.0-.25*a.x,.001,1.); float l = smoothstep(m+.025,m,a.y)*smoothstep(m-thick,m+.025-thick,a.y); float mask = l; gl_FragColor=vec4(vec3(0.),l); }

I’ve used animated inline shaders as content in earlier blog posts (such as the Ember’s breakdown), so this wasn’t going to need anything new, other than some fresh shaders.

But I can empathise with the worries you might have about this. Shaders on the web means using WebGL, and more specifically creating a canvas and WebGL context for each element in the page.

Since browsers tend to limit the total number of concurrent WebGL contexts, the code to manage those needs to be ready to recreate them at any time, say when an element scrolls back in screen.

In my case, I previously made a little javascript library to handle those issues, which also allows embedding fragment shaders directly inline in the page canvas element.

Combined with a live preview of the blog (I’m using hugo server -w), that makes sketching inline shaders quick and easy (and it also means that if you are not reading this on your phone, you can see the GLSL fragment-shader source of these images by checking the HTML source of this page in the developer view).

So this was going to be a pure exercise in creative coding.

void main() { vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5)); float t = time*1.9; float m = .001; m += clamp(.2*sin(acos(1.6*pow(abs(a.x-1.1),.4))),0.,1.); m += .5*clamp(.4*sin(acos(2.2*pow(abs(a.x-3.),2.))),0.,1.); m += clamp(.1*sin(7.*a.x-t*.1),0.,1.); m *= clamp(1.0-.25*a.x,.001,1.); float thick = .2+.19*sin(7.*a.x+.3*t)*sin(5.*sin(2.*a.x+.2*t)+t); thick *= clamp(1.0-.25*a.x,.001,1.); float l = smoothstep(m+.025,m,a.y)*smoothstep(m-thick,m+.025-thick,a.y); float mask = l; gl_FragColor=vec4(vec3(0.),l); }

The starting point was some googling for inspiration. A good set of keywords turned out to be “baroque divide lines”, which gives a nice selection of images in the kind of style I had in mind.

These are generally ornate, black figures on white pages. They typically feature thin lines, curves & bulges, horizontal symmetry, and often also vertical symmetry. Being page dividers, they also have an extreme aspect-ratio: very wide, but not tall.

Of course those traditional designs are static, not animated. So an interesting question here beyond replicating the style, was how to bring them alive with animation.

void main() { vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5)); vec2 c = vec2(3.,.2); vec2 p = c-a; float l = length(p); float n = fract(-.5*(atan(p.x,p.y))/3.14159267); float r = 0.2; float e = .02; float v = smoothstep(0.1,1.2,n)*smoothstep(1.2,0.1,n); float u = smoothstep(0.1,0.1+e,n)*smoothstep(1.2,1.2-e,n); float w = .2 * v * (1.+.5*sin(n*50.+time)); float m = 1.0*smoothstep(r-w,r-w+e,l)*smoothstep(r+w,r+w-e,l); float x = smoothstep(3.9+.1,2.,a.x)*(.01+(.02+.1*max(0.1,.3+1.*sin(2.7*a.x+time))*max(0.1,.1+1.2*sin(11.*a.x+2.7*time))*max(0.1,.1+1.*sin(17.*a.x+1.3*time)))); m += smoothstep(x,x-e,a.y); gl_FragColor=vec4(vec3(0.),m); }

I picked a standard canvas render resolution for all the images of 512 wide x 64 high, meaning an 8:1 aspect ratio, using the CSS style to rescale the result to 100% of the column width.

All the shaders start with a 2D-coordinate field having (0.0, 0.0) at the centre, and (4.0, 0.5) at the corners, like this:

   vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5));

The shader code needs to generate a mask value in the range 0.0-1.0 for each pixel, which is then used as the (pre-multiplied) output alpha (transparency) value for a black pixel. For example:

   gl_FragColor=vec4(vec3(0.),m);

To give the well defined smooth lines I want, the shader needs to approximate anti-aliasing of edges, with alpha values in between 0 and 1 representing the average (coverage percentage) of pixels containing both line and background.

void main() { vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5)); float v = fract(min(2.,(1.+.1*sin(3.*a.x+time))*(1.+smoothstep(0.,4.,a.x))*5.*a.y+(.0+.2*sin(a.x*7.+time*.3)))); float l = smoothstep(.1,.3,v)*smoothstep(.9,.7,v)*smoothstep(4.,3.9,a.x); gl_FragColor=vec4(vec3(0.),l); }

To do this, I used a selection of common distance-field rendering approaches.

For example, to draw a smooth horizontal line around the vertical centre, I start with a 2D vector value based on the y-coordinate (a.y), and feed that through a smoothstep function with two parameters representing the line width, and the width of the edge to be anti-aliased:

    float width = 0.05;
    float edge = 0.01;
    float m = smoothstep(width+edge, width, a.y);

It gives this as the result:

void main() { vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5)); float width = 0.05; float edge = 0.01; float m = smoothstep(width+edge, width, a.y); gl_FragColor=vec4(vec3(0.),m); }

Next, the line width value can be changed from a constant, into a function to derive a value from the x coordinate. For example:

    float width = (0.12+0.1*sin(7.0*a.x))
                  *smoothstep(4.0,1.0,a.x);

This one gives:

void main() { vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5)); float width = (0.12+0.1*sin(7.0*a.x)) *smoothstep(4.0,1.0,a.x); float edge = 0.02; float m = smoothstep(width+edge, width, a.y); gl_FragColor=vec4(vec3(0.),m); }

By taking in a time value (for the current frame being rendered) as an additional input, we can add in subtle evolving animation to bring the figures alive.

Here, we have two triangle waves added together, with sine waves modulating their phases at different frequencies:

    float t = .2*abs((fract(a.x+sin(time*.3))*2.)-1.)
                *smoothstep(4.,1.,a.x);
    t += .1*abs((fract(3.*a.x+sin(time*.1))*2.)-1.)
           *smoothstep(4.,1.,a.x);
    float l = smoothstep(.02+t,.02+t-.03,a.y)
             *smoothstep(4.,3.9,a.x);

This gives the (slightly more modern looking) result:

void main() { vec2 a = abs(gl_FragCoord.xy/64. - vec2(4.,.5)); float t = .2*abs((fract(a.x+sin(time*.3))*2.)-1.)*smoothstep(4.,1.,a.x); t += .1*abs((fract(3.*a.x+sin(time*.1))*2.)-1.)*smoothstep(4.,1.,a.x); float l = smoothstep(.02+t,.02+t-.03,a.y)*smoothstep(4.,3.9,a.x); gl_FragColor=vec4(vec3(0.),l); }

If you found this interesting, inspiring, or have any other thoughts or questions, please send them my way on twitter.