How to Create a WebGL Rotating Image Gallery using OGL and GLSL Shaders

Learn how to create a progressive image rotation effect with WebGL, using OGL and GLSL shaders.

Hello, everyone. I’m Seyi, a Creative Developer and Technical Director at Studio Null.

In this tutorial, we’ll learn how to build an infinite scrollable gallery where each image rotates dynamically based on its position. We’ll use OGL for this tutorial, but the effect can be reproduced using other WebGL libraries, such as ThreeJS or Curtainsjs.

At the end of the tutorial, you will have built this scroll animation:

A smooth plane rotation on scroll using shaders.

HTML Markup

First, we define a canvas where we’ll render our 3D environment.

<canvas id="gl"></canvas>

The Canvas Class

We then need to set up a couple of classes to get everything working, the first being the Canvas class, which I’ll walk us through.

import { Renderer, Camera, Transform, Plane } from "ogl";
import Media from "./Media.js";
import NormalizeWheel from "normalize-wheel";
import { lerp } from "../utils/math";
import AutoBind from "../utils/bind";

export default class Canvas {
  constructor() {
    this.images = [
      "/img/11.webp",
      "/img/2.webp",
      "/img/3.webp",
      "/img/4.webp",
      "/img/5.webp",
      "/img/6.webp",
      "/img/7.webp",
      "/img/8.webp",
      "/img/9.webp",
      "/img/10.webp",
    ];

    this.scroll = {
      ease: 0.01,
      current: 0,
      target: 0,
      last: 0,
    };

    AutoBind(this);

    this.createRenderer();
    this.createCamera();
    this.createScene();

    this.onResize();

    this.createGeometry();
    this.createMedias();

    this.update();

    this.addEventListeners();
    this.createPreloader();
  }

  createPreloader() {
    Array.from(this.images).forEach((source) => {
      const image = new Image();

      this.loaded = 0;

      image.src = source;
      image.onload = (_) => {
        this.loaded += 1;

        if (this.loaded === this.images.length) {
          document.documentElement.classList.remove("loading");
          document.documentElement.classList.add("loaded");
        }
      };
    });
  }

  createRenderer() {
    this.renderer = new Renderer({
      canvas: document.querySelector("#gl"),
      alpha: true,
      antialias: true,
      dpr: Math.min(window.devicePixelRatio, 2),
    });

    this.gl = this.renderer.gl;
  }
  createCamera() {
    this.camera = new Camera(this.gl);
    this.camera.fov = 45;
    this.camera.position.z = 20;
  }
  createScene() {
    this.scene = new Transform();
  }
  createGeometry() {
    this.planeGeometry = new Plane(this.gl, {
      heightSegments: 1,
      widthSegments: 100,
    });
  }
  createMedias() {
    this.medias = this.images.map((image, index) => {
      return new Media({
        gl: this.gl,
        geometry: this.planeGeometry,
        scene: this.scene,
        renderer: this.renderer,
        screen: this.screen,
        viewport: this.viewport,
        image,
        length: this.images.length,
        index,
      });
    });
  }
  onResize() {
    this.screen = {
      width: window.innerWidth,
      height: window.innerHeight,
    };

    this.renderer.setSize(this.screen.width, this.screen.height);

    this.camera.perspective({
      aspect: this.gl.canvas.width / this.gl.canvas.height,
    });

    const fov = this.camera.fov * (Math.PI / 180);
    const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
    const width = height * this.camera.aspect;

    this.viewport = {
      height,
      width,
    };
    if (this.medias) {
      this.medias.forEach((media) =>
        media.onResize({
          screen: this.screen,
          viewport: this.viewport,
        })
      );
    }
  }
  easeInOut(t) {
    return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
  }

  onTouchDown(event) {
    this.isDown = true;

    this.scroll.position = this.scroll.current;
    this.start = event.touches ? event.touches[0].clientY : event.clientY;
  }

  onTouchMove(event) {
    if (!this.isDown) return;

    const y = event.touches ? event.touches[0].clientY : event.clientY;
    const distance = (this.start - y) * 0.1;

    this.scroll.target = this.scroll.position + distance;
  }

  onTouchUp(event) {
    this.isDown = false;
  }

  onWheel(event) {
    const normalized = NormalizeWheel(event);
    const speed = normalized.pixelY;

    this.scroll.target += speed * 0.005;
  }

  update() {
    this.scroll.current = lerp(
      this.scroll.current,
      this.scroll.target,
      this.scroll.ease
    );

    if (this.scroll.current > this.scroll.last) {
      this.direction = "up";
    } else {
      this.direction = "down";
    }

    if (this.medias) {
      this.medias.forEach((media) => media.update(this.scroll, this.direction));
    }

    this.renderer.render({
      scene: this.scene,
      camera: this.camera,
    });

    this.scroll.last = this.scroll.current;

    window.requestAnimationFrame(this.update);
  }
  addEventListeners() {
    window.addEventListener("resize", this.onResize);
    window.addEventListener("wheel", this.onWheel);
    window.addEventListener("mousewheel", this.onWheel);

    window.addEventListener("mousedown", this.onTouchDown);
    window.addEventListener("mousemove", this.onTouchMove);
    window.addEventListener("mouseup", this.onTouchUp);

    window.addEventListener("touchstart", this.onTouchDown);
    window.addEventListener("touchmove", this.onTouchMove);
    window.addEventListener("touchend", this.onTouchUp);
  }
}

The first thing we need to do is set up all the logic required to render our environment.
We need a Camera, a Scene, and a Renderer, which we set up in their respective create functions. We use the Renderer to output everything into the canvas element we defined. We then render the scene on every frame in the update function.

import { Renderer, Camera, Transform, Plane } from "ogl";

createRenderer() {
    this.renderer = new Renderer({
      canvas: document.querySelector("#gl"), //canvas element
      alpha: true,
      antialias: true,
      dpr: Math.min(window.devicePixelRatio, 2),
    });

    this.gl = this.renderer.gl;
  }
  createCamera() {
    this.camera = new Camera(this.gl);
    this.camera.fov = 45;
    this.camera.position.z = 20;
  }
  createScene() {
    this.scene = new Transform();
  }

update() {
    this.renderer.render({
      scene: this.scene,
      camera: this.camera,
    });

    window.requestAnimationFrame(this.update.bind(this));
  }

We use the onResize function to do the following:

  1. Set the <canvas> size to the viewport width and height.
  2. Update the camera’s perspective to the new viewport sizes.
  3. We’ll calculate the viewport width and height needed to scale and position the plane. These values translate pixel values into 3D sizes.
onResize() {
    this.screen = {
      width: window.innerWidth,
      height: window.innerHeight,
    };

    this.renderer.setSize(this.screen.width, this.screen.height);

    this.camera.perspective({
      aspect: this.gl.canvas.width / this.gl.canvas.height,
    });

    const fov = this.camera.fov * (Math.PI / 180);
    const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
    const width = height * this.camera.aspect;

    this.viewport = {
      height,
      width,
    };
  }

Next, we preload the images and set up the Media class.

 this.images = [
      "/img/11.webp",
      "/img/2.webp",
      "/img/3.webp",
      "/img/4.webp",
      "/img/5.webp",
      "/img/6.webp",
      "/img/7.webp",
      "/img/8.webp",
      "/img/9.webp",
      "/img/10.webp",
    ];

createPreloader() {
    Array.from(this.images).forEach((source) => {
      const image = new Image();

      this.loaded = 0;

      image.src = source;
      image.onload = (_) => {
        this.loaded += 1;

        if (this.loaded === this.images.length) {
          document.documentElement.classList.remove("loading");
          document.documentElement.classList.add("loaded");
        }
      };
    });
  }

 createMedias() {
    this.medias = this.images.map((image, index) => {
      return new Media({
        gl: this.gl,
        geometry: this.planeGeometry,
        scene: this.scene,
        renderer: this.renderer,
        screen: this.screen,
        viewport: this.viewport,
        image,
        length: this.images.length,
        index,
      });
    });
  }

Next, we add in mouse, wheel and touch event listeners. We use the listener functions to update the scroll target value.

In the new update function, we interpolate between the current and target values to create a smooth scroll effect. We also determine the user’s scroll direction and pass all the scroll information to the Media update function.

 // declare an initial scroll value that we're going to update with the listener functions
 this.scroll = {
      ease: 0.01,
      current: 0,
      target: 0,
      last: 0,
 };

addEventListeners() {
    window.addEventListener("wheel", this.onWheel);
    window.addEventListener("mousewheel", this.onWheel);

    window.addEventListener("mousedown", this.onTouchDown);
    window.addEventListener("mousemove", this.onTouchMove);
    window.addEventListener("mouseup", this.onTouchUp);

    window.addEventListener("touchstart", this.onTouchDown);
    window.addEventListener("touchmove", this.onTouchMove);
    window.addEventListener("touchend", this.onTouchUp);
  }
}

onTouchDown(event) {
    this.isDown = true;

    this.scroll.position = this.scroll.current;
    this.start = event.touches ? event.touches[0].clientY : event.clientY;
  }

  onTouchMove(event) {
    if (!this.isDown) return;

    const y = event.touches ? event.touches[0].clientY : event.clientY;
    const distance = (this.start - y) * 0.1;

    this.scroll.target = this.scroll.position + distance;
  }

  onTouchUp(event) {
    this.isDown = false;
  }

  onWheel(event) {
    const normalized = NormalizeWheel(event);
    const speed = normalized.pixelY;

    this.scroll.target += speed * 0.005;
  }

// update function
 update() {
    this.scroll.current = lerp(
      this.scroll.current,
      this.scroll.target,
      this.scroll.ease
    );

    if (this.scroll.current > this.scroll.last) {
      this.direction = "up";
    } else {
      this.direction = "down";
    }

    if (this.medias) {
      this.medias.forEach((media) => media.update(this.scroll, this.direction));
    }

    this.renderer.render({
      scene: this.scene,
      camera: this.camera,
    });

    this.scroll.last = this.scroll.current;

    window.requestAnimationFrame(this.update);
  }

The Media Class

The Media class is where we’ll manage each image instance and add in our shader magic ✨

import { Mesh, Program, Texture } from "ogl";
import vertex from "../../shaders/vertex.glsl";
import fragment from "../../shaders/fragment.glsl";
import { map } from "../utils/math";

export default class Media {
  constructor({
    gl,
    geometry,
    scene,
    renderer,
    screen,
    viewport,
    image,
    length,
    index,
  }) {
    this.extra = 0;

    this.gl = gl;
    this.geometry = geometry;
    this.scene = scene;
    this.renderer = renderer;
    this.screen = screen;
    this.viewport = viewport;
    this.image = image;
    this.length = length;
    this.index = index;

    this.createShader();
    this.createMesh();

    this.onResize();
  }
  createShader() {
    const texture = new Texture(this.gl, {
      generateMipmaps: false,
    });

    this.program = new Program(this.gl, {
      depthTest: false,
      depthWrite: false,
      fragment,
      vertex,
      uniforms: {
        tMap: { value: texture },
        uPosition: { value: 0 },
        uPlaneSize: { value: [0, 0] },
        uImageSize: { value: [0, 0] },
        uSpeed: { value: 0 },
        rotationAxis: { value: [0, 1, 0] },
        distortionAxis: { value: [1, 1, 0] },
        uDistortion: { value: 3 },
        uViewportSize: { value: [this.viewport.width, this.viewport.height] },
        uTime: { value: 0 },
      },
      cullFace: false,
    });

    const image = new Image();

    image.src = this.image;
    image.onload = (_) => {
      texture.image = image;

      this.program.uniforms.uImageSize.value = [
        image.naturalWidth,
        image.naturalHeight,
      ];
    };
  }
  createMesh() {
    this.plane = new Mesh(this.gl, {
      geometry: this.geometry,
      program: this.program,
    });

    this.plane.setParent(this.scene);
  }

  setScale(x, y) {
    x = 320;
    y = 300;
    this.plane.scale.x = (this.viewport.width * x) / this.screen.width;
    this.plane.scale.y = (this.viewport.height * y) / this.screen.height;

    this.plane.program.uniforms.uPlaneSize.value = [
      this.plane.scale.x,
      this.plane.scale.y,
    ];
  }
  setX() {
    this.plane.position.x =
      -(this.viewport.width / 2) + this.plane.scale.x / 2 + this.x;
  }

  onResize({ screen, viewport } = {}) {
    if (screen) {
      this.screen = screen;
    }

    if (viewport) {
      this.viewport = viewport;
      this.plane.program.uniforms.uViewportSize.value = [
        this.viewport.width,
        this.viewport.height,
      ];
    }
    this.setScale();

    this.padding = 0.8;
    this.height = this.plane.scale.y + this.padding;

    this.heightTotal = this.height * this.length;

    this.y = this.height * this.index;
  }

  update(scroll, direction) {
    this.plane.position.y = this.y - scroll.current - this.extra;

    // map position from 5 to 15 depending on the scroll position
    const position = map(
      this.plane.position.y,
      -this.viewport.height,
      this.viewport.height,
      5,
      15
    );

    this.program.uniforms.uPosition.value = position;

    this.speed = scroll.current - scroll.last;

    this.program.uniforms.uTime.value += 0.04;
    this.program.uniforms.uSpeed.value = scroll.current;

    const planeOffset = this.plane.scale.y / 2;
    const viewportOffset = this.viewport.height;

    this.isBefore = this.plane.position.y + planeOffset < -viewportOffset;
    this.isAfter = this.plane.position.y - planeOffset > viewportOffset;

    if (direction === "up" && this.isBefore) {
      this.extra -= this.heightTotal;

      this.isBefore = false;
      this.isAfter = false;
    }

    if (direction === "down" && this.isAfter) {
      this.extra += this.heightTotal;

      this.isBefore = false;
      this.isAfter = false;
    }
  }
}

First, we use the MeshProgram and Texture classes from OGL to create a Plane and add our shaders and uniforms (including the texture).

createShader() {
    const texture = new Texture(this.gl, {
      generateMipmaps: false,
    });

    this.program = new Program(this.gl, {
      depthTest: false,
      depthWrite: false,
      fragment,
      vertex,
      uniforms: {
        tMap: { value: texture },
        uPosition: { value: 0 },
        uPlaneSize: { value: [0, 0] },
        uImageSize: { value: [0, 0] },
        uSpeed: { value: 0 },
        rotationAxis: { value: [0, 1, 0] },
        distortionAxis: { value: [1, 1, 0] },
        uDistortion: { value: 3 },
        uViewportSize: { value: [this.viewport.width, this.viewport.height] },
        uTime: { value: 0 },
      },
      cullFace: false,
    });

    const image = new Image();

    image.src = this.image;
    image.onload = (_) => {
      texture.image = image;

      this.program.uniforms.uImageSize.value = [
        image.naturalWidth,
        image.naturalHeight,
      ];
    };
  }
  createMesh() {
    this.plane = new Mesh(this.gl, {
      geometry: this.geometry,
      program: this.program,
    });

    this.plane.setParent(this.scene);
  }

Then we call the onResize event to set the size of the image.

onResize({ screen, viewport } = {}) {
    if (screen) {
      this.screen = screen;
    }

    if (viewport) {
      this.viewport = viewport;
      this.plane.program.uniforms.uViewportSize.value = [
        this.viewport.width,
        this.viewport.height,
      ];
    }
    this.setScale();
  }

  setScale(x, y) {
    x = 320;
    y = 300;
    this.plane.scale.x = (this.viewport.width * x) / this.screen.width;
    this.plane.scale.y = (this.viewport.height * y) / this.screen.height;

    this.plane.program.uniforms.uPlaneSize.value = [
      this.plane.scale.x,
      this.plane.scale.y,
    ];
  }

Next, we position the planes on their x and y axis.

// the spacing between planes
this.padding = 0.8;

this.height = this.plane.scale.y + this.padding;
this.heightTotal = this.height * this.length;

// initial plane position
this.y = this.height * this.index;

// position the image in the center of the screen on the x axis
setX() {
    this.plane.position.x =
      -(this.viewport.width / 2) + this.plane.scale.x / 2 + this.x;
 }

update(scroll, direction) {
    this.plane.position.y = this.y - scroll.current - this.extra;
}

Next, we do a bit of calculation and set some uniforms in the update function.

  update(scroll, direction) {
    this.plane.position.y = this.y - scroll.current - this.extra;

    // map position from 5 to 15 depending on the scroll position
    const position = map(
      this.plane.position.y,
      -this.viewport.height,
      this.viewport.height,
      5,
      15
    );

    this.program.uniforms.uPosition.value = position;

    this.speed = scroll.current - scroll.last;

    this.program.uniforms.uTime.value += 0.04;
    this.program.uniforms.uSpeed.value = scroll.current;

    const planeOffset = this.plane.scale.y / 2;
    const viewportOffset = this.viewport.height;

    this.isBefore = this.plane.position.y + planeOffset < -viewportOffset;
    this.isAfter = this.plane.position.y - planeOffset > viewportOffset;

    if (direction === "up" && this.isBefore) {
      this.extra -= this.heightTotal;

      this.isBefore = false;
      this.isAfter = false;
    }

    if (direction === "down" && this.isAfter) {
      this.extra += this.heightTotal;

      this.isBefore = false;
      this.isAfter = false;
    }
  }

In the update function, we do the following:

  • Update the plane’s position on the y-axis based on the scroll information we get from the Canvas class.
  • Set the uPosition uniform of the plane based on the plane position (mapped from one range to another). We’ll need this for the shader.
  • Update the uTime and uSpeed uniforms, also for the shader.
  • Write the infinite scroll logic. If the plane has reached the end of the scroll height, we place it back at the beginning, and if it has reached the beginning, we place it at the end.

The Fragment Shader

In the Fragment shader, we are basically using the uPlaneSize and uImageSize uniforms to display the images and mimic a CSS background-size: cover; behavior, but in WebGL.

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

void main() {
  vec2 ratio = vec2(
    min((uPlaneSize.x / uPlaneSize.y) / (uImageSize.x / uImageSize.y), 1.0),
    min((uPlaneSize.y / uPlaneSize.x) / (uImageSize.y / uImageSize.x), 1.0)
  );
 
  vec2 uv = vec2(
    vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
    vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
  );
  

 
  gl_FragColor.rgb = texture2D(tMap, uv).rgb;
  gl_FragColor.a = 1.0;
}

The Vertex Shader

In the Vertex shader, things are a bit more complex.

float offset = ( dot(distortionAxis,position) +norm/2.)/norm;

First, we get the offset, which is basically the degree of distortion we want to apply to each vertex. We use this by determining the relationship (dot product) between the vertex position and the distortion axis. We then normalize that value so we have something within a reasonable range.

float localprogress = clamp( (fract(uPosition * 5.0 * 0.01) - 0.01*uDistortion*offset)/(1. - 0.01*uDistortion),0.,2.); 

Next, we calculate the localprogess, which is basically a value that determines the current state of a transformation for each vertex on scroll, using the fract function to create a smooth repeating progression.

localprogress = qinticInOut(localprogress)*PI;

Next, we smoothen the progress using the qinticInOut function and multiply that by PI to give us an angular value in radians.

Finally, we use the rotate function to get the new position, which we use to set the gl_Position value.

precision highp float;
 
attribute vec3 position;
attribute vec2 uv;
attribute vec3 normal;
 
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;

uniform float uPosition;
uniform float uTime;
uniform float uSpeed;
uniform vec3 distortionAxis;
uniform vec3 rotationAxis;
uniform float uDistortion;
 
varying vec2 vUv;
varying vec3 vNormal;


float PI = 3.141592653589793238;
mat4 rotationMatrix(vec3 axis, float angle) {
    axis = normalize(axis);
    float s = sin(angle);
    float c = cos(angle);
    float oc = 1.0 - c;
    
    return mat4(oc * axis.x * axis.x + c,           oc * axis.x * axis.y - axis.z * s,  oc * axis.z * axis.x + axis.y * s,  0.0,
                oc * axis.x * axis.y + axis.z * s,  oc * axis.y * axis.y + c,           oc * axis.y * axis.z - axis.x * s,  0.0,
                oc * axis.z * axis.x - axis.y * s,  oc * axis.y * axis.z + axis.x * s,  oc * axis.z * axis.z + c,           0.0,
                0.0,                                0.0,                                0.0,                                1.0);
}

vec3 rotate(vec3 v, vec3 axis, float angle) {
	mat4 m = rotationMatrix(axis, angle);
	return (m * vec4(v, 1.0)).xyz;
}
float qinticInOut(float t) {
  return t < 0.5
    ? +16.0 * pow(t, 5.0)
    : -0.5 * abs(pow(2.0 * t - 2.0, 5.0)) + 1.0;
}

 
void main() {
  vUv = uv;

  float norm = 0.5;
  
  vec3 newpos = position;
  float offset = ( dot(distortionAxis,position) +norm/2.)/norm;

  float localprogress = clamp( (fract(uPosition * 5.0 * 0.01) - 0.01*uDistortion*offset)/(1. - 0.01*uDistortion),0.,2.); 

  localprogress = qinticInOut(localprogress)*PI;

  newpos = rotate(newpos,rotationAxis,localprogress);
 
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newpos, 1.0);
}

And you have your effect!

Thank you for reading! I hope you have fun recreating the effect.

Oluwaseyi Oluwadare

Web Engineer/Creative Technologist.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!