Background-cover with WebGL

Nicolas Giannantonio
3 min readAug 1, 2024

--

I am writing this article to clarify a recent discovery for myself and to have a reference that I can consult in the future to understand its functionality. If you are looking to do something similar, I hope this article will help you understand it.

The CSS property background-attachment: fixed allows for maintaining a fixed background while scrolling the page. Although this technique was commonly used in the past, it is now often replaced by a JavaScript method. This method involves translating the image within a div that has the overflow: hidden property.

There are different ways to achieve this effect. Personally, I didn’t want to use a library or perform the image transformation with a div using overflow: hidden. My goal was to do it in WebGL.

We will not cover the creation of the mesh and the normalization of the image data. This article focuses on explaining the shader and the transformation needed to achieve the desired effect.

In our vertex shader, we will add two new uniforms:

  • uImagePosition: Allows moving the image in a given direction (±xy).
  • uPlaneSizes: Allows adjusting the scale of the UV coordinates.
// Vertex shader

precision highp float;

attribute vec3 position;
attribute vec2 uv;

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

uniform vec2 uImagePosition;
uniform vec2 uPlaneSizes;

varying vec2 vUv;

void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vUv = uImagePosition + .5 + (uv - .5) / uPlaneSizes;
}

And then simply used in the fragment shader:

// Fragment shader

precision highp float;
varying vec2 vUv;
uniform sampler2D tMap;

void main() {
gl_FragColor = texture2D(tMap, vUv);
}

By adjusting uImagePosition[1], we can translate our image along the Y-axis. When uImagePosition[1] is 1.0, the image is shifted by 100% of its height. To achieve a smooth transition, we need to ensure that the offset is equal to -(offset / 2) when the image enters the viewport, and (offset / 2) when it exits. This allows us to have a well-distributed offset.

First, we need to calculate the distance between the top of the image and the top of the page:

const distance = bounds.top - scroll - window.innerHeight;

Next, we need to calculate the view, which is the sum of window.innerHeight and the total height of the image:

const view = window.innerHeight + bounds.height;

We need to define the translation offset for our image. For example, with an offset of 0.15, the image will translate by 15%.

const offset = .15;

Now we need to calculate the value of our Y-axis transformation: Next, we calculate the value of the transformation on the Y-axis. We need to determine how many times the view fits into the distance between the top of the page and our image, then multiply this ratio by -offset.

const ratio = ((distance / view) * -offset) - (offset / 2);

A problem arises if our image is not zoomed in, as the translation of the image without any margin can cause visible stretching. To avoid this, we can repeat the texture with the following parameters:

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); 
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);

We need to apply a zoom to the image using uPlaneSizes, as mentioned earlier.

this.program.uniforms.uPlaneSizes.value = [scaleWidth * this.zoom, scaleHeight * this.zoom];

This parameter should be set manually for each instance of the image, as it allows for individual control of the zoom and offset. Here is an example of configuring an object with the desired parameters:

const glImage = new GlImageTransform("gl-image", {
offset: .15,
zoom: 1.2,
});

--

--