Portfolio 2021 technical case study — Rendering a whole HTML website in WebGL

The journey

When a few years ago I discovered the power of WebGL and shaders, I instantly thought that one day, I’d really like to build a website fully rendered by WebGL. Yes, I do mean drawing every DOM elements of a website with WebGL : images of course, but also text blocks, links, buttons and so forth. Of course I wasn’t solely focusing on this, but I kept the idea in mind.

Rendering the text elements

In the end of 2020, as I had some free time on my schedule, I worked on the last piece of the puzzle that was missing: rendering multi line blocks of texts in WebGL.

Scroll effect

To emphasize this idea I thought I could add a little effect that would make the users feel like they’re actually “dragging” the content while scrolling. All those planes are therefore rendered to a Frame Buffer Object (FBO, or render target). The output texture is then drawn onto a fullscreen quad with the desired post-processing effect applied.

Scroll to see the magic happen!

The fluid effect

During those past years I’ve also been experimenting a lot and I’ve came upon what is called Frame Buffer Object swapping (or FBO ping pong). This opened to me the doors to some super cool effects, including this ripple simulation:

Original ripples effect from CodePen above.
Some of the different tests I’ve made while trying to find the result that would suit our needs.
From the original raw FBO ping pong texture output (yellow), to a traditional ripple effect (grayscale) to the final fluid effect (white & pink)
// ligths & shadows
float lightStrength = 5.0;
float lights = max(0.001, ripples.r - (0.7 + lightStrength * 0.025));
float shadow = max(0.001, 1.0 - (ripples.r + 0.5));

// get values to mix white and the colors for lights
// decrease lights area by multiplicating it with a float < 1
// then apply a pow() operation to sharpen the area
// finally multiply back the result with a big float number to increase the effect strength
float lightMixValue = clamp(pow(lights, 3.5) * 5.0, 0.0, 1.0);
float lightMixValue2 = clamp(pow(lights * 0.95, 5.0) * 10.0, 0.0, 1.0);
float lightMixValue3 = clamp(pow(lights * 0.9, 6.5) * 15.0, 0.0, 1.0);
float lightMixValue4 = clamp(pow(lights * 0.85, 7.0) * 30.0, 0.0, 1.0);
float lightMixValue5 = clamp(pow(lights * 0.8, 7.25) * 70.0, 0.0, 1.0);
float lightMixValue6 = clamp(pow(lights * 0.75, 8.5) * 100.0, 0.0, 1.0);
float lightMixValue7 = clamp(pow(lights * 0.7, 10.0) * 50.0, 0.0, 1.0);

// apply colors to lights
// [fluid1 ... fluid7] are predefined vec3 colors
vec3 lightColor = mix(color.rgb, fluid1, lightMixValue);
lightColor = mix(lightColor, fluid2, lightMixValue2);
lightColor = mix(lightColor, fluid3, lightMixValue3);
lightColor = mix(lightColor, fluid4, lightMixValue4);
lightColor = mix(lightColor, fluid5, lightMixValue5);
lightColor = mix(lightColor, fluid6, lightMixValue6);
lightColor = mix(lightColor, fluid7, lightMixValue7);
// repeat for shadowsvec3 lightColor = mix(color.rgb, fluid1, lightMixValue);
lightColor = mix(lightColor, fluid2, lightMixValue2);
lightColor = mix(lightColor, fluid3, lightMixValue3);
lightColor = mix(lightColor, fluid4, lightMixValue4);
lightColor = mix(lightColor, fluid5, lightMixValue5);
lightColor = mix(lightColor, fluid6, lightMixValue6);
lightColor = mix(lightColor, fluid7, lightMixValue7);
Some screenshots of our exporations
I must confess it was so fun that we spent way too much time looking for the right colors combination by just playing with the controls.

Rendering the scene and post-processing

Achieving the final result implied a few successive rendering steps and draw calls:

  • Draw the FBO ping pong ripples.
  • Use a first render target, where I’ll draw all the text elements that are not fixed so I could distort them on scroll.
  • Draw the header text elements that should not be affected by the scroll effect.
  • Finally, use a last render target pass where I’ll take all my scene elements (the background ripples and the foreground containing all the rendered DOM elements) and mix them altogether to display the final result.

Mixing two textures based on their alpha in GLSL

In my output fragment shader, I had to mix the foreground layer (all the texts, images, buttons and other elements contained in the DOM) with the background layer (the fluid effect) based on the foreground alpha value.
I’ve tried several ways to achieve it, including mix(), step() and smoothstep() operations, but in the end what gave the sharpest result was this snippet:

Page transitions

The last thing that I had to take care of was adding some nice animated page transitions.

  • show the overlay by animating the uTransition uniform from 0 to 1.
  • do the heavy stuff: remove the current WebGL meshes and add the new ones. There are still some frame drops but you can’t see them since the overlay is covering the whole scene!
  • hide the overlay by animating back the uTransition uniform from 1 to 0.
Page transitions concept (just play with the transition input to show / hide the overlay)

Wrapping it up

Now that everything was in place, I just had to actually build the actual portfolio.

Workflow

As the content wasn’t going to change much over the time, I’ve decided not to use any CMS. I used plain old HTML, CSS, and ES6 Javascript. The build files were generated by esbuild which is super fast and easy to set up.

  • PJAX router: AJAX navigation that allows animated transitions between routes.
  • Native smooth scroll: A non obtrusive smooth scroll library that eases the scroll values while keeping the native scrollbar and keyboard navigation features.
  • Scroll Observer: Detects when elements enter or leave the viewport based on the Intersection Observer API and triggers according callbacks.

Performance and accessibility

When it comes to WebGL, performance and accessibility are always two major concerns.

Conclusion

I hope that this case study helped you understand how I’ve been able to render every HTML DOM elements of a website with WebGL.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store