Rock the Stage with a Smooth WebGL Shader Transformation on Scroll

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 ...

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!

The post Rock the Stage with a Smooth WebGL Shader Transformation on Scroll appeared first on Codrops.