Whats up, everybody. I’m Seyi, a Inventive Developer and Technical Director at Studio Null.
On this tutorial, we’ll discover ways to construct an infinite scrollable gallery the place every picture rotates dynamically based mostly on its place. We’ll use OGL for this tutorial, however the impact may be reproduced utilizing different WebGL libraries, comparable to ThreeJS or Curtainsjs.
On the finish of the tutorial, you should have constructed this scroll animation:
HTML Markup
First, we outline a canvas the place we’ll render our 3D surroundings.
<canvas id="gl"></canvas>
The Canvas Class
We then have to arrange a few lessons to get every little thing working, the primary being the Canvas class, which I’ll stroll us by means of.
import { Renderer, Digital camera, Remodel, Aircraft } 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.pictures = [
"/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,
present: 0,
goal: 0,
final: 0,
};
AutoBind(this);
this.createRenderer();
this.createCamera();
this.createScene();
this.onResize();
this.createGeometry();
this.createMedias();
this.replace();
this.addEventListeners();
this.createPreloader();
}
createPreloader() {
Array.from(this.pictures).forEach((supply) => {
const picture = new Picture();
this.loaded = 0;
picture.src = supply;
picture.onload = (_) => {
this.loaded += 1;
if (this.loaded === this.pictures.size) {
doc.documentElement.classList.take away("loading");
doc.documentElement.classList.add("loaded");
}
};
});
}
createRenderer() {
this.renderer = new Renderer({
canvas: doc.querySelector("#gl"),
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio, 2),
});
this.gl = this.renderer.gl;
}
createCamera() {
this.digicam = new Digital camera(this.gl);
this.digicam.fov = 45;
this.digicam.place.z = 20;
}
createScene() {
this.scene = new Remodel();
}
createGeometry() {
this.planeGeometry = new Aircraft(this.gl, {
heightSegments: 1,
widthSegments: 100,
});
}
createMedias() {
this.medias = this.pictures.map((picture, index) => {
return new Media({
gl: this.gl,
geometry: this.planeGeometry,
scene: this.scene,
renderer: this.renderer,
display: this.display,
viewport: this.viewport,
picture,
size: this.pictures.size,
index,
});
});
}
onResize() {
this.display = {
width: window.innerWidth,
peak: window.innerHeight,
};
this.renderer.setSize(this.display.width, this.display.peak);
this.digicam.perspective({
side: this.gl.canvas.width / this.gl.canvas.peak,
});
const fov = this.digicam.fov * (Math.PI / 180);
const peak = 2 * Math.tan(fov / 2) * this.digicam.place.z;
const width = peak * this.digicam.side;
this.viewport = {
peak,
width,
};
if (this.medias) {
this.medias.forEach((media) =>
media.onResize({
display: this.display,
viewport: this.viewport,
})
);
}
}
easeInOut(t) {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
onTouchDown(occasion) {
this.isDown = true;
this.scroll.place = this.scroll.present;
this.begin = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
}
onTouchMove(occasion) {
if (!this.isDown) return;
const y = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
const distance = (this.begin - y) * 0.1;
this.scroll.goal = this.scroll.place + distance;
}
onTouchUp(occasion) {
this.isDown = false;
}
onWheel(occasion) {
const normalized = NormalizeWheel(occasion);
const pace = normalized.pixelY;
this.scroll.goal += pace * 0.005;
}
replace() {
this.scroll.present = lerp(
this.scroll.present,
this.scroll.goal,
this.scroll.ease
);
if (this.scroll.present > this.scroll.final) {
this.route = "up";
} else {
this.route = "down";
}
if (this.medias) {
this.medias.forEach((media) => media.replace(this.scroll, this.route));
}
this.renderer.render({
scene: this.scene,
digicam: this.digicam,
});
this.scroll.final = this.scroll.present;
window.requestAnimationFrame(this.replace);
}
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 very first thing we have to do is about up all of the logic required to render the environment.
We’d like a Digital camera, a Scene, and a Renderer, which we arrange of their respective create features. We use the Renderer to output every little thing into the canvas aspect we outlined. We then render the scene on each body within the replace operate.
import { Renderer, Digital camera, Remodel, Aircraft } from "ogl";
createRenderer() {
this.renderer = new Renderer({
canvas: doc.querySelector("#gl"), //canvas aspect
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio, 2),
});
this.gl = this.renderer.gl;
}
createCamera() {
this.digicam = new Digital camera(this.gl);
this.digicam.fov = 45;
this.digicam.place.z = 20;
}
createScene() {
this.scene = new Remodel();
}
replace() {
this.renderer.render({
scene: this.scene,
digicam: this.digicam,
});
window.requestAnimationFrame(this.replace.bind(this));
}
We use the onResize operate to do the next:
- Set the
<canvas>measurement to the viewport width and peak. - Replace the digicam’s perspective to the brand new viewport sizes.
- We’ll calculate the viewport width and peak wanted to scale and place the airplane. These values translate pixel values into 3D sizes.
onResize() {
this.display = {
width: window.innerWidth,
peak: window.innerHeight,
};
this.renderer.setSize(this.display.width, this.display.peak);
this.digicam.perspective({
side: this.gl.canvas.width / this.gl.canvas.peak,
});
const fov = this.digicam.fov * (Math.PI / 180);
const peak = 2 * Math.tan(fov / 2) * this.digicam.place.z;
const width = peak * this.digicam.side;
this.viewport = {
peak,
width,
};
}
Subsequent, we preload the photographs and arrange the Media class.
this.pictures = [
"/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.pictures).forEach((supply) => {
const picture = new Picture();
this.loaded = 0;
picture.src = supply;
picture.onload = (_) => {
this.loaded += 1;
if (this.loaded === this.pictures.size) {
doc.documentElement.classList.take away("loading");
doc.documentElement.classList.add("loaded");
}
};
});
}
createMedias() {
this.medias = this.pictures.map((picture, index) => {
return new Media({
gl: this.gl,
geometry: this.planeGeometry,
scene: this.scene,
renderer: this.renderer,
display: this.display,
viewport: this.viewport,
picture,
size: this.pictures.size,
index,
});
});
}
Subsequent, we add in mouse, wheel and contact occasion listeners. We use the listener features to replace the scroll goal worth.
Within the new replace operate, we interpolate between the present and goal values to create a clean scroll impact. We additionally decide the person’s scroll route and go all of the scroll info to the Media replace operate.
// declare an preliminary scroll worth that we'll replace with the listener features
this.scroll = {
ease: 0.01,
present: 0,
goal: 0,
final: 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(occasion) {
this.isDown = true;
this.scroll.place = this.scroll.present;
this.begin = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
}
onTouchMove(occasion) {
if (!this.isDown) return;
const y = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
const distance = (this.begin - y) * 0.1;
this.scroll.goal = this.scroll.place + distance;
}
onTouchUp(occasion) {
this.isDown = false;
}
onWheel(occasion) {
const normalized = NormalizeWheel(occasion);
const pace = normalized.pixelY;
this.scroll.goal += pace * 0.005;
}
// replace operate
replace() {
this.scroll.present = lerp(
this.scroll.present,
this.scroll.goal,
this.scroll.ease
);
if (this.scroll.present > this.scroll.final) {
this.route = "up";
} else {
this.route = "down";
}
if (this.medias) {
this.medias.forEach((media) => media.replace(this.scroll, this.route));
}
this.renderer.render({
scene: this.scene,
digicam: this.digicam,
});
this.scroll.final = this.scroll.present;
window.requestAnimationFrame(this.replace);
}
The Media Class
The Media class is the place we’ll handle every picture occasion 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,
display,
viewport,
picture,
size,
index,
}) {
this.additional = 0;
this.gl = gl;
this.geometry = geometry;
this.scene = scene;
this.renderer = renderer;
this.display = display;
this.viewport = viewport;
this.picture = picture;
this.size = size;
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: { worth: texture },
uPosition: { worth: 0 },
uPlaneSize: { worth: [0, 0] },
uImageSize: { worth: [0, 0] },
uSpeed: { worth: 0 },
rotationAxis: { worth: [0, 1, 0] },
distortionAxis: { worth: [1, 1, 0] },
uDistortion: { worth: 3 },
uViewportSize: { worth: [this.viewport.width, this.viewport.height] },
uTime: { worth: 0 },
},
cullFace: false,
});
const picture = new Picture();
picture.src = this.picture;
picture.onload = (_) => {
texture.picture = picture;
this.program.uniforms.uImageSize.worth = [
image.naturalWidth,
image.naturalHeight,
];
};
}
createMesh() {
this.airplane = new Mesh(this.gl, {
geometry: this.geometry,
program: this.program,
});
this.airplane.setParent(this.scene);
}
setScale(x, y) {
x = 320;
y = 300;
this.airplane.scale.x = (this.viewport.width * x) / this.display.width;
this.airplane.scale.y = (this.viewport.peak * y) / this.display.peak;
this.airplane.program.uniforms.uPlaneSize.worth = [
this.plane.scale.x,
this.plane.scale.y,
];
}
setX() {
this.airplane.place.x =
-(this.viewport.width / 2) + this.airplane.scale.x / 2 + this.x;
}
onResize({ display, viewport } = {}) {
if (display) {
this.display = display;
}
if (viewport) {
this.viewport = viewport;
this.airplane.program.uniforms.uViewportSize.worth = [
this.viewport.width,
this.viewport.height,
];
}
this.setScale();
this.padding = 0.8;
this.peak = this.airplane.scale.y + this.padding;
this.heightTotal = this.peak * this.size;
this.y = this.peak * this.index;
}
replace(scroll, route) {
this.airplane.place.y = this.y - scroll.present - this.additional;
// map place from 5 to fifteen relying on the scroll place
const place = map(
this.airplane.place.y,
-this.viewport.peak,
this.viewport.peak,
5,
15
);
this.program.uniforms.uPosition.worth = place;
this.pace = scroll.present - scroll.final;
this.program.uniforms.uTime.worth += 0.04;
this.program.uniforms.uSpeed.worth = scroll.present;
const planeOffset = this.airplane.scale.y / 2;
const viewportOffset = this.viewport.peak;
this.isBefore = this.airplane.place.y + planeOffset < -viewportOffset;
this.isAfter = this.airplane.place.y - planeOffset > viewportOffset;
if (route === "up" && this.isBefore) {
this.additional -= this.heightTotal;
this.isBefore = false;
this.isAfter = false;
}
if (route === "down" && this.isAfter) {
this.additional += this.heightTotal;
this.isBefore = false;
this.isAfter = false;
}
}
}
First, we use the Mesh, Program and Texture lessons from OGL to create a Aircraft and add our shaders and uniforms (together with the feel).
createShader() {
const texture = new Texture(this.gl, {
generateMipmaps: false,
});
this.program = new Program(this.gl, {
depthTest: false,
depthWrite: false,
fragment,
vertex,
uniforms: {
tMap: { worth: texture },
uPosition: { worth: 0 },
uPlaneSize: { worth: [0, 0] },
uImageSize: { worth: [0, 0] },
uSpeed: { worth: 0 },
rotationAxis: { worth: [0, 1, 0] },
distortionAxis: { worth: [1, 1, 0] },
uDistortion: { worth: 3 },
uViewportSize: { worth: [this.viewport.width, this.viewport.height] },
uTime: { worth: 0 },
},
cullFace: false,
});
const picture = new Picture();
picture.src = this.picture;
picture.onload = (_) => {
texture.picture = picture;
this.program.uniforms.uImageSize.worth = [
image.naturalWidth,
image.naturalHeight,
];
};
}
createMesh() {
this.airplane = new Mesh(this.gl, {
geometry: this.geometry,
program: this.program,
});
this.airplane.setParent(this.scene);
}
Then we name the onResize occasion to set the scale of the picture.
onResize({ display, viewport } = {}) {
if (display) {
this.display = display;
}
if (viewport) {
this.viewport = viewport;
this.airplane.program.uniforms.uViewportSize.worth = [
this.viewport.width,
this.viewport.height,
];
}
this.setScale();
}
setScale(x, y) {
x = 320;
y = 300;
this.airplane.scale.x = (this.viewport.width * x) / this.display.width;
this.airplane.scale.y = (this.viewport.peak * y) / this.display.peak;
this.airplane.program.uniforms.uPlaneSize.worth = [
this.plane.scale.x,
this.plane.scale.y,
];
}
Subsequent, we place the planes on their x and y axis.
// the spacing between planes
this.padding = 0.8;
this.peak = this.airplane.scale.y + this.padding;
this.heightTotal = this.peak * this.size;
// preliminary airplane place
this.y = this.peak * this.index;
// place the picture within the heart of the display on the x axis
setX() {
this.airplane.place.x =
-(this.viewport.width / 2) + this.airplane.scale.x / 2 + this.x;
}
replace(scroll, route) {
this.airplane.place.y = this.y - scroll.present - this.additional;
}
Subsequent, we do a little bit of calculation and set some uniforms within the replace operate.
replace(scroll, route) {
this.airplane.place.y = this.y - scroll.present - this.additional;
// map place from 5 to fifteen relying on the scroll place
const place = map(
this.airplane.place.y,
-this.viewport.peak,
this.viewport.peak,
5,
15
);
this.program.uniforms.uPosition.worth = place;
this.pace = scroll.present - scroll.final;
this.program.uniforms.uTime.worth += 0.04;
this.program.uniforms.uSpeed.worth = scroll.present;
const planeOffset = this.airplane.scale.y / 2;
const viewportOffset = this.viewport.peak;
this.isBefore = this.airplane.place.y + planeOffset < -viewportOffset;
this.isAfter = this.airplane.place.y - planeOffset > viewportOffset;
if (route === "up" && this.isBefore) {
this.additional -= this.heightTotal;
this.isBefore = false;
this.isAfter = false;
}
if (route === "down" && this.isAfter) {
this.additional += this.heightTotal;
this.isBefore = false;
this.isAfter = false;
}
}
Within the replace operate, we do the next:
- Replace the airplane’s place on the y-axis based mostly on the scroll info we get from the
Canvasclass. - Set the
uPositionuniform of the airplane based mostly on the airplane place (mapped from one vary to a different). We’ll want this for the shader. - Replace the
uTimeand uSpeed uniforms, additionally for the shader. - Write the infinite scroll logic. If the airplane has reached the top of the scroll peak, we place it again initially, and if it has reached the start, we place it on the finish.
The Fragment Shader
Within the Fragment shader, we’re mainly utilizing the uPlaneSize and uImageSize uniforms to show the photographs and mimic a CSS background-size: cowl; conduct, however in WebGL.
precision highp float;
uniform vec2 uImageSize;
uniform vec2 uPlaneSize;
uniform sampler2D tMap;
various vec2 vUv;
void most important() {
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
Within the Vertex shader, issues are a bit extra advanced.
float offset = ( dot(distortionAxis,place) +norm/2.)/norm;
First, we get the offset, which is mainly the diploma of distortion we need to apply to every vertex. We use this by figuring out the connection (dot product) between the vertex place and the distortion axis. We then normalize that worth so now we have one thing inside an inexpensive vary.
float localprogress = clamp( (fract(uPosition * 5.0 * 0.01) - 0.01*uDistortion*offset)/(1. - 0.01*uDistortion),0.,2.);
Subsequent, we calculate the localprogess, which is mainly a price that determines the present state of a metamorphosis for every vertex on scroll, utilizing the fract operate to create a clean repeating development.
localprogress = qinticInOut(localprogress)*PI;
Subsequent, we smoothen the progress utilizing the qinticInOut operate and multiply that by PI to present us an angular worth in radians.
Lastly, we use the rotate operate to get the brand new place, which we use to set the gl_Position worth.
precision highp float;
attribute vec3 place;
attribute vec2 uv;
attribute vec3 regular;
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;
various vec2 vUv;
various 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 most important() {
vUv = uv;
float norm = 0.5;
vec3 newpos = place;
float offset = ( dot(distortionAxis,place) +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’ve got your impact!
Thanks for studying! I hope you have got enjoyable recreating the impact.


