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:
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:
- Set the
<canvas>
size to the viewport width and height. - Update the camera’s perspective to the new viewport sizes.
- 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 Mesh
, Program
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.