— Pixels Commander

[ In English, На русском ]

Control every pixel of your Web application flexibly keeping performance high with GLSL components

5 years ago shaders transformed game graphics and became the technology behind all amazing VFX we see in computer games. Now they are ready to rock the Web. Shaders are little programs written in C-like language GLSL (OpenGL Shading Language) which are aimed for defining the state of vertices (vertex shaders) and pixels (fragment shaders) in OpenGL / WebGL context using math equations. GLSL compiles and runs at GPU achieving unprecedented performance for HTML/CSS world. Shaders are widely used in game development and 3d graphics apps providing unlimited abilities for implementing special effects and rendering techniques however for Web development GLSL is still underutilized despite wide browsers support. This article reviews real world shaders usage for Web UI development and provides some how-to`s on integrating GLSL component into your Web application.

First of all let`s get some clarity as for why shaders were introduced, how they look like and how do they work...

What are GLSL shaders?

With advance of videocards and raise of GPU power developers started to feel lack of customization capabilities for hardcoded OpenGL rendering algorithm. Shaders became programmable blocks of OpenGL rendering pipeline which allow to modify data coming through in order to implement special effects and customize rendering. They were added with OpenGL version 2.0 which was released in 2004 and was the biggest change to OpenGL specification since first one introduced in 1992.

As it was already mentioned there are two types of shaders: (vertex shaders) and fragment shaders. We see their position in a pipeline at a chart following.

Vertex shader executes first and modify geometry. After geometry is rasterized fragment shader is being executed for every fragment (pixel) of image defining it`s color. As Web UIs are mostly 2d we are not that interested in modifying geometry and will focus on fragment shaders.

Let`s have a step by step look at fragment shader which renders a circle:

float radius = 20.; vec2 center = vec2(100., 100.); void main() { float distanceToCenter = distance(gl_FragCoord.xy, center); float inCircle = float(distanceToCenter < radius); gl_FragColor = vec4(inCircle); }

First we define constants for radius, center, then goes main function declaration which is a standard entry point for any shader. In the beginning of main we determine if current pixel is inside of circle. To achieve this let`s find distance from current pixel gl_FragCoord.xy to circle center (100, 100) and compare if it is less than radius. Thereafter inCircle going to be 1 if distance is less than radius and 0 if distance is greater. In last line we define color by assigning it`s value to a system variable (gl_FragColor). GLSL accepts RGBA as a color format, where every channel value is represented by number kept into range between 0 and 1. So when inCircle is equal to 0 we have 0 for all channels which means black pixel and vice versa if inCircle is 1 then we have a white pixel R=1, G=1, B=1, A=1.

Let`s break the barrier between you and GLSL. In the editor above try to change radius, center coordinates and the last line of main function to look like that gl_FragColor = vec4(.75, 1., .2, 1.0) * inCircle;.

This is how shaders work in a nutshell. Now we move on to a story about practical use case for GLSL web components.

Optimizing with CSS and it`s downsides

Once I was asked to optimize spinner in operation system we develop. Spinner lagged a lot when there were some processes running in background and pretty a lot of frames were missing. Of course having this kind of janks on spinner caused really bad user experience. I quickly figured out that component was implemented via spritesheet with background-position animated which caused component repaint on every frame.

Preloader with repaints

Obvious, that this kind of animation can not be fast (especially running on low-end devices many of our clients own) because new texture was painted and uploaded to GPU on every frame and we definitely should optimize it by changing the way animation is implemented. Basic approach would be to reimplement spinner animation using GPU accelerated CSS properties: scale / translate / skew / opacity). From these we can use opacity since every frame can be turned into separate GPU accelerated layer and then displayed / hidden by changing opacity. As a result we`ve got 150 div`s uploaded to GPU (with translateZ(0) hack) and displayed one by one by changing opacity from 0 to 1 and vice versa.

Not bad! We got rid of repaints and QA department confirmed performance improvement.

Few days later I was asked to apply the same optimization for similar spinner having another color and size. That moment it became clear that CSS solution is not flexible at all. We need to load one more spritesheet with correct size / color, and also we need to keep additionally 150 textures in video memory which is 150*120*120*4=8,64 mb based on spinner 150 frames and 120px dimensions multiplied by 4 bytes per pixel (32bit RGBA). If we will need one more size or color - we create third spritesheet and load 8.6 mb more to GPU and so on... Sounds like a problem which can be solved by using GLSL shader which provides way better flexibility.

GLSL implementation

Advantage of GLSL - ability to control every pixel of surface and wide parametrization abilities, and of course shaders run on GPU by default which means no hacks there. Let`s have a look at step by step implementation of such a spinner in GLSL to have a taste of water. To render such a spinner we need to draw an arc with dynamically changing angle and some rotation. For this we will need a bit of math. Let`s start with circle we already did by making this piece of code a bit more scalable.

float PI = 3.14159; vec2 center = u_resolution * 0.5; float radius = min(u_resolution.x, u_resolution.y) * .5; float circle(vec2 coord, vec2 center, float radius) { float distanceToCenter = distance(coord, center); return step(distanceToCenter, radius); } void main() { vec2 coord = vec2(gl_FragCoord); float isFilled = circle(coord, center, radius); gl_FragColor = vec4(1. - isFilled); }

Now we use uniform variable u_resolution sent from JS which reflects size of current WebGL context which makes it possible to calculate center of current surface and distance from center to boundaries in order to draw the biggest circle possible for a current canvas. Also we moved detection of is point in circle to a separate function and inverted colors by substracting isFilled from 1.

Further on this circle we should cut a sector defining start angle and end angle for it. Essentially here we should determine "If current point lies between angles A and B". This is not that easy to determine as it looks like at the first sight. Pitfalls and solution are described well here, I saw more elegant solutions but they have more disadvantages than this one.

So we define two new functions - isAngleBetween and sector which calculates angle between X axis and line from center to current pixel and return 1 if this angle is between start and end angles of sector required. Now by multiplying result of circle and sector we`ve got an arc.

bool isAngleBetween(float target, float angle1, float angle2) { float startAngle = min(angle1, angle2); float endAngle = max(angle1, angle2); if (endAngle - startAngle < 0.1) { return false; } target = mod((360. + (mod(target, 360.))), 360.); startAngle = mod((3600000. + startAngle), 360.); endAngle = mod((3600000. + endAngle), 360.); if (startAngle < endAngle) return startAngle <= target && target <= endAngle; return startAngle <= target || target <= endAngle; } float sector(vec2 coord, vec2 center, float startAngle, float endAngle) { vec2 uvToCenter = coord - center; float angle = degrees(atan(uvToCenter.y, uvToCenter.x)); if (isAngleBetween(angle, startAngle, endAngle)) { return 1.0; } else { return 0.; } } void main() { vec2 coord = vec2(gl_FragCoord); float isFilled = circle(coord, center, radius) * sector(coord, center, 0., 75.); gl_FragColor = vec4(1. - isFilled); }

Next let`s cut inner radius of circle and move arc rendering to a separate function.

float arc(vec2 uv, vec2 center, float startAngle, float endAngle, float innerRadius, float outerRadius) { float result = 0.0; result = sector(uv, center, startAngle, endAngle) * circle(uv, center, outerRadius) * (1.0 - circle(uv, center, innerRadius)); return result; } void main() { vec2 coord = vec2(gl_FragCoord); float width = 7.; float outerRadius = min(u_resolution.x, u_resolution.y) * .5; float innerRadius = outerRadius - width; float isFilled = arc(coord, center, 0., 75., innerRadius, outerRadius); gl_FragColor = vec4(1. - isFilled); }

Arc is quite pixelated. This happens because step function sharply discard values below given. Let`s change it for smoothstep to get some antialiasing there.

float circle(vec2 coord, vec2 center, float radius) { float distanceToCenter = distance(coord, center); //Before //return step(distanceToCenter, radius); //After return smoothstep(distanceToCenter - 2., distanceToCenter, radius); }

Now we are about to start with implementing animation which really need us to keep eye on details. Spinner animation can be decomposed into two: arc edges movement and whole spinner rotation. Let`s begin with moving arc edges. As we see from original spinner:

  • Front edge accelerate during whole round.
  • At the moment when front edge ends it`s turn rear edge start moving immediately having maximum speed (same as front edge had) and then decelerates at the end of round.

These smooth acceleration / deceleration easings could be implemented by using sine function. Passing reminder from dividing current time by Pi and substrating Pi/2 as argument. In this way time will always be in the range from -Pi/2 to Pi/2.

Sine graph for spinner easings

Let`s keep time in variable named periodicTime. For front edge of arc we are interested in limting time in the range from -Pi/2 to 0, and for rear edge from 0 to Pi/2. Let`s implement this clamping of periodicTime using GLSL function clamp and save it to variables startX, endX which we pass to sine function. Also for negative sine values expected for startX according to graph we add 1 to avoid negative angles. Start / end angles in degrees we get by multiplying sine results by 360.

void main() { vec2 coord = vec2(gl_FragCoord); float width = 7.; float halfPI = PI * .5; float periodicTime = mod(u_time, PI) - halfPI; float outerRadius = min(u_resolution.x, u_resolution.y) * .5; float innerRadius = outerRadius - width; float startX = clamp(periodicTime, -halfPI, 0.); float endX = clamp(periodicTime, 0., halfPI); float angleVariation = sin(startX) + 1.; float endAngleVariation = sin(endX); float startAngle = 360. * angleVariation; float endAngle = 360. * endAngleVariation; float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius); gl_FragColor = vec4(1. - isFilled); }

Wow. Spinner is almost there! Next we add a rotation. Rotation accelerates in the beginning of the round and breaks in the end which corresponds to sine graph in the range of -Pi/2 to Pi/2, however with two times longer period than we have for edges. Also let`s rotate spinner in order animation to start at top of round by substracting 90 degrees from startAngle/endAngle .

//main float rotation = 180. * (sin(periodicTime) + 1.); float startAngle = 360. * angleVariation + rotation - 90.; float endAngle = 360. * endAngleVariation + rotation - 90.; float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius); gl_FragColor = vec4(1. - isFilled);

So what is left is to set correct color. We can use RGB color value (45,121,184) for this but should write rgb function which converts byte - length channels to channels in the range of 0 - 1. Also let`s make backgroundColor customizable without forgetting about colors inversion.

float width = 2.; vec4 color = rgb(45., 121., 184.); vec4 backgroundColor = vec4(1.); float innerRadius = outerRadius - width; float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius); gl_FragColor = (backgroundColor - (backgroundColor - color) * isFilled);

Try to change value of width variable and arguments passed to rgb function, this will immediately lead to changes in spinner size and color. So, now we have customizable spinner animated with GPU which is already more than we can achieve with CSS however let`s go further in order to understand the power of GLSL to control every pixel flexibly keeping performance high.

Let`s make pixel`s color dependent on current angle of rotation and arc width dependent on distance to mouse pointer.

float distanceToMouse = distance(u_mouse, gl_FragCoord.xy) * 0.085; float width = 50. - clamp(5. * distanceToMouse, 5., 45.); vec4 color = rgb(255. * (sin(periodicTime) + 1.), 60. * distanceToMouse / 2., 160.) * (radius / distance(gl_FragCoord.xy, center)); vec4 backgroundColor = vec4(0.); float innerRadius = outerRadius - width; float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius); gl_FragColor = (backgroundColor - (backgroundColor - color) * isFilled);

Try moving mouse over spinner. This kind of things is not possible to implement with CSS.

Deploying to project

GLSL-Component was created as a solution for integration GLSL components into Web application. Just place your shader code between glsl-component tags.

Conclusion

Some components can be implemented using GLSL way more efficiently than they can be made with CSS. This is also promoted by wide support of WebGL across all modern browsers. Flexibility, control over every pixel, high performance of GLSL are advantages which may inspire you to contribute into developing GLSL components for Web applications. GLSL - is not only a good addition to your UI developer profile but also a great treatment against JavaScript fatigue.

What is next?

Reading The Book of Shaders may be a good start.