WebGL Background Bump Mapping

February 1, 2023

In 2012 I wrote a small WebGL shader to improve the depth of static background images, using tiling textures and Phong shading to produce a bump mapped effect. Nearly ten years later, I revitalized it.

A bump mapped website background rendered with WebGL
The original version of this code: A repeating texture image and WebGL create a real-time bump mapping effect

What was it?#

Background textures had always been fairly static things. I set out to increase their apparent depth. This project uses WebGL and tiling background images to produce a bump mapped effect.

Another bump mapped background texture
Another tiling pattern with the bump mapping shader applied

Overview#

The website's background (0,0)x(viewportX,viewportY) is mapped to a square (-1,-1)x(1,1). Right about where your face is, a virtual light is pointed towards the screen. I use the Phong reflection model to do some simple shading. You provide any tiling background image, and your image is consulted for the texture of the surface.

The tiling textures used on this page were found at Subtle Patterns.

Graphics Model#

The image is represented in the XY plane. The normal is simply (0,0,1) (pointing at you). This allows us to easily place the light somewhere in the (0,0,+z) area, and use the mouse to control the X and Y positions of the light. The Phong reflection model is used for shading. Since the viewport is defined to be between (-1,-1)x(1,1), we can easily reason about the position of the light in this space. (-1,-1) being the bottom right, (1,1) being the top right. An offset can be applied to the mouse's modifier, allowing us to position the light off-screen but still have the mouse affect the shading, making the shading effect subtler. I try to push the mouse about 2 units away vertically or horizontally because I think that looks prettier.

Bump mapping#

Forward differencing (via 3 texture lookups) in X (aka s) and Y (aka t) is performed on the provided pattern, and a magnitude is obtained. The step size can be controlled by delta_s and delta_t, and may need to be adjusted as the texture is scaled for better results. So, for example with the s axis, we're looking at a pixel and the pixel 0.05 to the right, subtracting them, and using this as a vector to perturb the normal. This magnitude, modulated by a user specified scaling factor (bump_scale), is used to perturb the normals.

Currently, only the blue channel of this difference is consulted. At first it seems surprising that the result can be as good as it is when we ignore 66% of the color of the texture. I think this can be explained intuitively by realizing that the pixels of most patterns have some amount of RGB in every pixel. I.e., rarely does a red consist purely of only the red channel. We might be in trouble if someone created a pattern without color in the blue channel! The main contribution of color in the result is coming primarily from the diffuse color. The diffuse shading is 80% diffuse color (user-supplied RGB) plus 20% of the RGB of the actual texture image. This is done so that we don't end up with lack of color when light shines parallel to lines in the texture (in these cases, the difference is nearly 0.0, and would mean no normal perturbation). This might not be necessary with a better differencing algorithm.

Viewport Adjustments#

It is very rare that a browser will have a viewport aspect ratio of 1:1. The canvas is stretched to the browser viewport dimensions, but internally has a square of aspect 1:1. As such, we'd notice stretching on any textures due to this aspect ratio difference. So, on draw/resize, the texture coordinates are modulated by the browser's viewport aspect ratio. This results in square textures displaying as they were intended.

WebGL and power-of-two textures#

As I was experimenting with the textures, I noticed that a lot of the tiling textures that exist in the wild are not power-of-two (hereafter, POT) in dimension. Furthermore, almost none of the textures are perfectly square.

WebGL only currently supports POT pixel dimensions, so the original pattern image is redrawn using a temporary 2D canvas if it isn't a POT. This is fine for getting the texture to simply display, but there are still rescaling problems (mapping an arbitrary rectangle into a perfect square squashes the texture). Ultimately, this is due to a deficiency in the simplified model I constructed, but it is easily circumvented: the texture coordinates are further modulated by the texture's aspect ratio. This results in textures of any aspect ratios rendering properly.

Texture Coordinate Scaling#

In the original version of this code, the texture scale would have to be adjusted depending on the input image. In this more recent revision, I've updated that so that 1.0 represents the original texture at 1:1 size, regardless of either texture size or canvas size. Scales above 1.0 will be blurry (as any image stretch will be).

A Physically Based Rendering approach#

With the advent of AI, we can totally circumvent all of my old struggles and efforts and replicate an even more powerful version of this effect using Physically Based Rendering (PBR) approach, implemented in mere minutes!

We can use some free PBR textures, which include:

  • albedo: base color
  • normal map: small surface details
  • roughness map: affects the shape of the light highlight
  • ambient occlusion (AO) map: subtle contact shading

Normal, roughness, and AO are all optional textures, so a plain texture still works with this shader approach.

I took it a step further and we authored a lighting system that sort of simulates global lighting. No matter where the DOM element is on the page, the coordinates of the mouse are interpreted as global coordinates of the light, so the light appears to follow your global cursor even if the viewport for the scene is a small or large DOM element, leading to a semi-seamless effect.

Then, I thought, maybe we can add a bit of depth, so I added the concept of a customizable "relief". The following component is inset 24px deep into the page, and if you move your mouse near the edges, you can see the shadows cast by the "hole" in the page.

Don't fall into the charcoal zone.

Naturally, the opposite of a hole is... not a hole, I guess. So I thought a chamfered edge might look interesting, and so we see the following component has a customizable chamfer depth and bezel depth. The top, right, bottom, and left are all individually customizable.

Per-edge chamfers catch the light along the edges.

You can visit the PBR background lab to play with more options.

Conclusion and reflections#

Just as when I wrote the original version in 2012, the utility of this is dubious. But it's fun. And I think the web can always use fun :)

The original code from 2012 was unusable due to bitrot and general sloppiness of the original code, but I was able to copy and paste large amounts of it and refactor some of it. It's been interesting reflecting on the old code as I write the new. I wondered about the amount of effort being spent to continually rebuild software as things change. The web doesn't really feel like a substrate where you can build something and then leave it for 50 years. Sometimes web APIs change from under my code and the code no longer works. An example of this is the web fullscreen API that I used to use for my <canvas> elements: this worked at one point and then embarrassingly became an inert button years later.

With the latest version, I haven't even looked at the code. I think it's a good example of a black box we humans don't really need to care about.

Perhaps the 2030 version will be "hey chat, redo this entire page in a realtime raytraced shader with global illumination"

© 2026 Anthony Cameron