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:
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.
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.
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.
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 -
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
sector we`ve got an arc.
Next let`s cut inner radius of circle and move arc rendering to a separate function.
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.
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.
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
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.
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 .
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.
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.
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.
What is next?
Reading The Book of Shaders may be a good start.