From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
It’s fascinating which magical effects you can add to a website when you experiment with vertex displacement. Today we’d like to share a method with you that you can use to create your own WebGL shader animation linked to scroll progress. It’s a great way to learn how to bind shader vertices and colors to user interactions and to find the best flow.
We’ll be using Pug, Sass, Three.js and GSAP for our project.
Let’s rock!
The stage
For our flexible scroll stage, we quickly create three sections with Pug. By adding an element to the sections
array, it’s easy to expand the stage.
index.pug:
.scroll__stage
.scroll__content
- const sections = ['Logma', 'Naos', 'Chara']
each section, index in sections
section.section
.section__title
h1.section__title-number= index < 9 ? `0${index + 1}` : index + 1
h2.section__title-text= section
p.section__paragraph The fireball that we rode was moving – But now we've got a new machine – They got music in the solar system
br
a.section__button Discover
The sections are quickly formatted with Sass, the mixins we will need later.
index.sass:
%top
top: 0
left: 0
width: 100%
%fixed
@extend %top
position: fixed
%absolute
@extend %top
position: absolute
*,
*::after,
*::before
margin: 0
padding: 0
box-sizing: border-box
.section
display: flex
justify-content: space-evenly
align-items: center
width: 100%
min-height: 100vh
padding: 8rem
color: white
background-color: black
&:nth-child(even)
flex-direction: row-reverse
background: blue
/* your design */
Now we write our ScrollStage
class and set up a scene with Three.js. The camera
range of 10
is enough for us here. We already prepare the loop for later instructions.
index.js:
import * as THREE from 'three'
class ScrollStage {
constructor() {
this.element = document.querySelector('.content')
this.viewport = {
width: window.innerWidth,
height: window.innerHeight,
}
this.scene = new THREE.Scene()
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true
})
this.canvas = this.renderer.domElement
this.camera = new THREE.PerspectiveCamera(
75,
this.viewport.width / this.viewport.height,
.1,
10
)
this.clock = new THREE.Clock()
this.update = this.update.bind(this)
this.init()
}
init() {
this.addCanvas()
this.addCamera()
this.addEventListeners()
this.onResize()
this.update()
}
/**
* STAGE
*/
addCanvas() {
this.canvas.classList.add('webgl')
document.body.appendChild(this.canvas)
}
addCamera() {
this.camera.position.set(0, 0, 2.5)
this.scene.add(this.camera)
}
/**
* EVENTS
*/
addEventListeners() {
window.addEventListener('resize', this.onResize.bind(this))
}
onResize() {
this.viewport = {
width: window.innerWidth,
height: window.innerHeight
}
this.camera.aspect = this.viewport.width / this.viewport.height
this.camera.updateProjectionMatrix()
this.renderer.setSize(this.viewport.width, this.viewport.height)
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5))
}
/**
* LOOP
*/
update() {
this.render()
window.requestAnimationFrame(this.update)
}
/**
* RENDER
*/
render() {
this.renderer.render(this.scene, this.camera)
}
}
new ScrollStage()
We disable the pointer events and let the canvas blend.
index.sass:
...
canvas.webgl
@extend %fixed
pointer-events: none
mix-blend-mode: screen
...
Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.
The rockstar
We create a mesh
, assign a icosahedron geometry
and set the blending
of its material
to additive for loud colors. And – I like the wireframe
style. For now, we set the value
of all uniforms
to 0
(uOpacity
to 1
).
I usually scale down the mesh for portrait screens. With only one object, we can do it this way. Otherwise you better transform the camera.position.z
.
Let’s rotate our sphere slowly.
index.js:
...
import vertexShader from './shaders/vertex.glsl'
import fragmentShader from './shaders/fragment.glsl'
...
init() {
...
this.addMesh()
...
}
/**
* OBJECT
*/
addMesh() {
this.geometry = new THREE.IcosahedronGeometry(1, 64)
this.material = new THREE.ShaderMaterial({
wireframe: true,
blending: THREE.AdditiveBlending,
transparent: true,
vertexShader,
fragmentShader,
uniforms: {
uFrequency: { value: 0 },
uAmplitude: { value: 0 },
uDensity: { value: 0 },
uStrength: { value: 0 },
uDeepPurple: { value: 0 },
uOpacity: { value: 1 }
}
})
this.mesh = new THREE.Mesh(this.geometry, this.material)
this.scene.add(this.mesh)
}
...
onResize() {
...
if (this.viewport.width < this.viewport.height) {
this.mesh.scale.set(.75, .75, .75)
} else {
this.mesh.scale.set(1, 1, 1)
}
...
}
update() {
const elapsedTime = this.clock.getElapsedTime()
this.mesh.rotation.y = elapsedTime * .05
...
}
In the vertex shader (which positions the geometry) and fragment shader (which assigns a color to the pixels) we control the values of the uniforms that we will get from the scroll position. To generate an organic randomness, we make some noise. This shader program runs now on the GPU.
/shaders/vertex.glsl:
#pragma glslify: pnoise = require(glsl-noise/periodic/3d)
#pragma glslify: rotateY = require(glsl-rotate/rotateY)
uniform float uFrequency;
uniform float uAmplitude;
uniform float uDensity;
uniform float uStrength;
varying float vDistortion;
void main() {
float distortion = pnoise(normal * uDensity, vec3(10.)) * uStrength;
vec3 pos = position + (normal * distortion);
float angle = sin(uv.y * uFrequency) * uAmplitude;
pos = rotateY(pos, angle);
vDistortion = distortion;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.);
}
/shaders/fragment.glsl:
uniform float uOpacity;
uniform float uDeepPurple;
varying float vDistortion;
vec3 cosPalette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
float distort = vDistortion * 3.;
vec3 brightness = vec3(.1, .1, .9);
vec3 contrast = vec3(.3, .3, .3);
vec3 oscilation = vec3(.5, .5, .9);
vec3 phase = vec3(.9, .1, .8);
vec3 color = cosPalette(distort, brightness, contrast, oscilation, phase);
gl_FragColor = vec4(color, vDistortion);
gl_FragColor += vec4(min(uDeepPurple, 1.), 0., .5, min(uOpacity, 1.));
}
If you don’t understand what’s happening here, I recommend this tutorial by Mario Carrillo.
The soundcheck
To find your preferred settings, you can set up a dat.gui for example. I’ll show you another approach here, in which you can combine two (or more) parameters to intuitively find a cool flow of movement. We simply connect the uniform
values with the normalized values of the mouse event and log them to the console. As we use this approach only for development, we do not call rAF (requestAnimationFrames
).
index.js:
...
import GSAP from 'gsap'
...
constructor() {
...
this.mouse = {
x: 0,
y: 0
}
this.settings = {
// vertex
uFrequency: {
start: 0,
end: 0
},
uAmplitude: {
start: 0,
end: 0
},
uDensity: {
start: 0,
end: 0
},
uStrength: {
start: 0,
end: 0
},
// fragment
uDeepPurple: { // max 1
start: 0,
end: 0
},
uOpacity: { // max 1
start: 1,
end: 1
}
}
...
}
addEventListeners() {
...
window.addEventListener('mousemove', this.onMouseMove.bind(this))
}
onMouseMove(event) {
// play with it!
// enable / disable / change x, y, multiplier …
this.mouse.x = (event.clientX / this.viewport.width).toFixed(2) * 4
this.mouse.y = (event.clientY / this.viewport.height).toFixed(2) * 2
GSAP.to(this.mesh.material.uniforms.uFrequency, { value: this.mouse.x })
GSAP.to(this.mesh.material.uniforms.uAmplitude, { value: this.mouse.x })
GSAP.to(this.mesh.material.uniforms.uDensity, { value: this.mouse.y })
GSAP.to(this.mesh.material.uniforms.uStrength, { value: this.mouse.y })
// GSAP.to(this.mesh.material.uniforms.uDeepPurple, { value: this.mouse.x })
// GSAP.to(this.mesh.material.uniforms.uOpacity, { value: this.mouse.y })
console.info(`X: ${this.mouse.x} | Y: ${this.mouse.y}`)
}
The support act
To create a really fluid mood, we first implement our smooth scroll.
index.sass:
body
overscroll-behavior: none
width: 100%
height: 100vh
...
.scroll
&__stage
@extend %fixed
height: 100vh
&__content
@extend %absolute
will-change: transform
SmoothScroll.js:
import GSAP from 'gsap'
export default class {
constructor({ element, viewport, scroll }) {
this.element = element
this.viewport = viewport
this.scroll = scroll
this.elements = {
scrollContent: this.element.querySelector('.scroll__content')
}
}
setSizes() {
this.scroll.height = this.elements.scrollContent.getBoundingClientRect().height
this.scroll.limit = this.elements.scrollContent.clientHeight - this.viewport.height
document.body.style.height = `${this.scroll.height}px`
}
update() {
this.scroll.hard = window.scrollY
this.scroll.hard = GSAP.utils.clamp(0, this.scroll.limit, this.scroll.hard)
this.scroll.soft = GSAP.utils.interpolate(this.scroll.soft, this.scroll.hard, this.scroll.ease)
if (this.scroll.soft < 0.01) {
this.scroll.soft = 0
}
this.elements.scrollContent.style.transform = `translateY(${-this.scroll.soft}px)`
}
onResize() {
this.viewport = {
width: window.innerWidth,
height: window.innerHeight
}
this.setSizes()
}
}
index.js:
...
import SmoothScroll from './SmoothScroll'
...
constructor() {
...
this.scroll = {
height: 0,
limit: 0,
hard: 0,
soft: 0,
ease: 0.05
}
this.smoothScroll = new SmoothScroll({
element: this.element,
viewport: this.viewport,
scroll: this.scroll
})
...
}
...
onResize() {
...
this.smoothScroll.onResize()
...
}
update() {
...
this.smoothScroll.update()
...
}
The show
Finally, let’s rock the stage!
Once we have chosen the start
and end
values, it’s easy to attach them to the scroll position. In this example, we want to drop the purple mesh through the blue section so that it is subsequently soaked in blue itself. We increase the frequency and the strength of our vertex displacement. Let’s first enter this values in our settings
and update the mesh material
. We normalize scrollY
so that we can get the values from 0 to 1 and make our calculations with them.
To render the shader only while scrolling, we call rAF by the scroll listener. We don’t need the mouse event listener anymore.
To improve performance, we add an overwrite
to the GSAP default settings. This way we kill any existing tweens while generating a new one for every frame. A long duration
renders the movement extra smooth. Once again we let the object rotate slightly with the scroll movement. We iterate over our settings
and GSAP makes the music.
index.js:
constructor() {
...
this.scroll = {
...
normalized: 0,
running: false
}
this.settings = {
// vertex
uFrequency: {
start: 0,
end: 4
},
uAmplitude: {
start: 4,
end: 4
},
uDensity: {
start: 1,
end: 1
},
uStrength: {
start: 0,
end: 1.1
},
// fragment
uDeepPurple: { // max 1
start: 1,
end: 0
},
uOpacity: { // max 1
start: .33,
end: .66
}
}
GSAP.defaults({
ease: 'power2',
duration: 6.6,
overwrite: true
})
this.updateScrollAnimations = this.updateScrollAnimations.bind(this)
...
}
...
addMesh() {
...
uniforms: {
uFrequency: { value: this.settings.uFrequency.start },
uAmplitude: { value: this.settings.uAmplitude.start },
uDensity: { value: this.settings.uDensity.start },
uStrength: { value: this.settings.uStrength.start },
uDeepPurple: { value: this.settings.uDeepPurple.start },
uOpacity: { value: this.settings.uOpacity.start }
}
}
...
addEventListeners() {
...
// window.addEventListener('mousemove', this.onMouseMove.bind(this)) // enable to find your preferred values (console)
window.addEventListener('scroll', this.onScroll.bind(this))
}
...
/**
* SCROLL BASED ANIMATIONS
*/
onScroll() {
this.scroll.normalized = (this.scroll.hard / this.scroll.limit).toFixed(1)
if (!this.scroll.running) {
window.requestAnimationFrame(this.updateScrollAnimations)
this.scroll.running = true
}
}
updateScrollAnimations() {
this.scroll.running = false
GSAP.to(this.mesh.rotation, {
x: this.scroll.normalized * Math.PI
})
for (const key in this.settings) {
if (this.settings[key].start !== this.settings[key].end) {
GSAP.to(this.mesh.material.uniforms[key], {
value: this.settings[key].start + this.scroll.normalized * (this.settings[key].end - this.settings[key].start)
})
}
}
}
Thanks for reading this tutorial, hope you like it!
Try it out, go new ways, have fun – dare a stage dive!