---
title: WebGL Background Bump Mapping
date: 2023-02-01
summary: 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.
published: true
image: background-bumps.jpg
---

<PbrBackgroundTexture slot="hero" texture="tiles-138" relief="50" scale="800" />

![A bump mapped website background rendered with WebGL](background-bumps.jpg "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](background-bumps2.jpg#right "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](https://www.toptal.com/designers/subtlepatterns/).

## 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.

<PbrBackgroundTexture texture="scorched-charcoal" scale="360" relief="24" caption="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.

<PbrBackgroundTexture
  texture="wood-066"
  scale="520"
  caption="Per-edge chamfers catch the light along the edges."
  chamfer='{"top":{"bezel":20,"depth":150},"right":{"bezel":20,"depth":150},"bottom":{"bezel":20,"depth":150},"left":{"bezel":20,"depth":150}}'
/>

You can visit the [PBR background lab](/lab/pbr-background) 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"