Skip to content

Instantly share code, notes, and snippets.

@JoshOohAhhAi
Created October 12, 2024 12:51
Show Gist options
  • Save JoshOohAhhAi/a418827dd58cbd7273da3c67bfe9e287 to your computer and use it in GitHub Desktop.
Save JoshOohAhhAi/a418827dd58cbd7273da3c67bfe9e287 to your computer and use it in GitHub Desktop.
On-Scroll Fire Transition (WebGL + GSAP ScrollTrigger)
<div class="page">
<div class="header">
How it's done
</div>
<div class="content">
<p>
The HTML content you're reading right now is overlaid with a full-screen <b>&lt;canvas&gt;</b> element.
There is a fragment shader that defines opacity and color for each pixel of the <b>&lt;canvas&gt;</b>.
Shader input values are <b>scroll progress (aka animation progress)</b>, <b>time</b>, and <b>resolution</b>.
</p>
<p>
While <b>time</b> and <b>window size (resolution)</b> are super easy to gather, for <b>animation progress</b> I use <a href="https://gsap.com/docs/v3/Plugins/ScrollTrigger/" target="_blank">GSAP ScrollTrigger</a> plugin.
</p>
<p>
Once the inputs are prepared, we pass them as uniforms to the shader.
The WebGL part of this demo is a basic JS boilerplate to render a fragment shader on the single full-screen plane. No extra libraries here.
</p>
<p>
The fragment shader is based on <a href="https://thebookofshaders.com/13/" target="_blank">Fractal Brownian Motion (fBm)</a> noise.
</p>
<p>
First, we create a semi-transparent mask to define a contour of burning paper. It is basically a low-scale fBm noise with <b>scroll progress</b> value used as a threshold.
Taking the same fBm noise with different thresholds we can
<br>
(a) darken parts of the paper so each pixel gets darker before turning transparent
<br>
(b) define the stripe along the paper edge and use it as a mask for flames
</p>
<p>
The fire is done as another two fBm based functions - one for shape and one for color. Both have a much higher scale and both are animated with <b>time</b> value instead of <b>scroll progress</b>.
</p>
<p class="last-line">
<a href="https://www.linkedin.com/in/ksenia-kondrashova/" target="_blank">linkedIn</a> | <a href="https://codepen.io/ksenia-k" target="_blank">codepen</a> | <a href="https://twitter.com/uuuuuulala" target="_top">twitter (X)</a>
</p>
</div>
</div>
<canvas id="fire-overlay"></canvas>
<div class="scroll-msg">
<div>Hello 👋</div>
<div>scroll me</div>
<div class="arrow-animated">&darr;</div>
</div>
<script type="x-shader/x-fragment" id="vertShader">
precision mediump float;
varying vec2 vUv;
attribute vec2 a_position;
void main() {
vUv = a_position;
gl_Position = vec4(a_position, 0.0, 1.0);
}
</script>
<script type="x-shader/x-fragment" id="fragShader">
precision mediump float;
varying vec2 vUv;
uniform vec2 u_resolution;
uniform float u_progress;
uniform float u_time;
float rand(vec2 n) {
return fract(cos(dot(n, vec2(12.9898, 4.1414))) * 43758.5453);
}
float noise(vec2 n) {
const vec2 d = vec2(0., 1.);
vec2 b = floor(n), f = smoothstep(vec2(0.0), vec2(1.0), fract(n));
return mix(mix(rand(b), rand(b + d.yx), f.x), mix(rand(b + d.xy), rand(b + d.yy), f.x), f.y);
}
float fbm(vec2 n) {
float total = 0.0, amplitude = .4;
for (int i = 0; i < 4; i++) {
total += noise(n) * amplitude;
n += n;
amplitude *= 0.6;
}
return total;
}
void main() {
vec2 uv = vUv;
uv.x *= min(1., u_resolution.x / u_resolution.y);
uv.y *= min(1., u_resolution.y / u_resolution.x);
float t = u_progress;
vec3 color = vec3(1., 1., .95);
float main_noise = 1. - fbm(.75 * uv + 10. - vec2(.3, .9 * t));
float paper_darkness = smoothstep(main_noise - .1, main_noise, t);
color -= vec3(.99, .95, .99) * paper_darkness;
vec3 fire_color = fbm(6. * uv - vec2(0., .005 * u_time)) * vec3(6., 1.4, .0);
float show_fire = smoothstep(.4, .9, fbm(10. * uv + 2. - vec2(0., .005 * u_time)));
show_fire += smoothstep(.7, .8, fbm(.5 * uv + 5. - vec2(0., .001 * u_time)));
float fire_border = .02 * show_fire;
float fire_edge = smoothstep(main_noise - fire_border, main_noise - .5 * fire_border, t);
fire_edge *= (1. - smoothstep(main_noise - .5 * fire_border, main_noise, t));
color += fire_color * fire_edge;
float opacity = 1. - smoothstep(main_noise - .0005, main_noise, t);
gl_FragColor = vec4(color, opacity);
}
</script>
const canvasEl = document.querySelector("#fire-overlay");
const scrollMsgEl = document.querySelector(".scroll-msg");
const devicePixelRatio = Math.min(window.devicePixelRatio, 2);
// const devicePixelRatio = 1;
const params = {
fireTime: .35,
fireTimeAddition: 0
}
let st, uniforms;
const gl = initShader();
st = gsap.timeline({
scrollTrigger: {
trigger: ".page",
start: "0% 0%",
end: "100% 100%",
// markers: true,
scrub: true,
onLeaveBack: () => {
// params.fireTimeAddition = Math.min(params.fireTimeAddition, .3);
// gsap.to(params, {
// duration: .75,
// fireTimeAddition: 0
// })
},
},
})
.to(scrollMsgEl, {
duration: .1,
opacity: 0
}, 0)
.to(params, {
fireTime: .63
}, 0)
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
gsap.set(".page", {
opacity: 1
})
function initShader() {
const vsSource = document.getElementById("vertShader").innerHTML;
const fsSource = document.getElementById("fragShader").innerHTML;
const gl = canvasEl.getContext("webgl") || canvasEl.getContext("experimental-webgl");
if (!gl) {
alert("WebGL is not supported by your browser.");
}
function createShader(gl, sourceCode, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, sourceCode);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
const vertexShader = createShader(gl, vsSource, gl.VERTEX_SHADER);
const fragmentShader = createShader(gl, fsSource, gl.FRAGMENT_SHADER);
function createShaderProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("Unable to initialize the shader program: " + gl.getProgramInfoLog(program));
return null;
}
return program;
}
const shaderProgram = createShaderProgram(gl, vertexShader, fragmentShader);
uniforms = getUniforms(shaderProgram);
function getUniforms(program) {
let uniforms = [];
let uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < uniformCount; i++) {
let uniformName = gl.getActiveUniform(program, i).name;
uniforms[uniformName] = gl.getUniformLocation(program, uniformName);
}
return uniforms;
}
const vertices = new Float32Array([-1., -1., 1., -1., -1., 1., 1., 1.]);
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.useProgram(shaderProgram);
const positionLocation = gl.getAttribLocation(shaderProgram, "a_position");
gl.enableVertexAttribArray(positionLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
return gl;
}
function render() {
const currentTime = performance.now();
gl.uniform1f(uniforms.u_time, currentTime);
// if (st.scrollTrigger.isActive && st.scrollTrigger.direction === 1) {
// params.fireTimeAddition += .001;
// }
gl.uniform1f(uniforms.u_progress, params.fireTime + params.fireTimeAddition);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(render);
}
function resizeCanvas() {
canvasEl.width = window.innerWidth * devicePixelRatio;
canvasEl.height = window.innerHeight * devicePixelRatio;
gl.viewport(0, 0, canvasEl.width, canvasEl.height);
gl.uniform2f(uniforms.u_resolution, canvasEl.width, canvasEl.height);
render();
}
<script src="https://unpkg.com/gsap@3/dist/gsap.min.js"></script>
<script src="https://unpkg.com/gsap@3/dist/ScrollTrigger.min.js"></script>
body, html {
margin: 0;
padding: 0;
font-family: sans-serif;
font-size: 20px;
color: #3d3d3d;
}
a {
color: inherit;
}
.page {
width: 100%;
min-height: 180vh;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
}
.page .header {
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
text-transform: uppercase;
width: 100vw;
margin-top: 20vh;
height: 25vh;
}
.page .content {
max-width: 800px;
padding: 10px;
}
.page .last-line {
text-align: right;
padding-top: 1em;
}
.page ::-moz-selection {
background: #F7C02D;
}
.page ::selection {
background: #F7C02D;
}
.scroll-msg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
padding-top: 2em;
}
.scroll-msg > div:nth-child(1) {
margin-top: -10vh;
padding-bottom: 1em;
text-transform: uppercase;
font-size: 2em;
}
canvas#fire-overlay {
position: fixed;
top: 0;
left: 0;
display: block;
width: 100%;
pointer-events: none;
}
.arrow-animated {
font-size: 1em;
animation: arrow-float 1s infinite;
}
@keyframes arrow-float {
0% {
transform: translateY(0);
animation-timing-function: ease-out;
}
60% {
transform: translateY(50%);
animation-timing-function: ease-in-out;
}
100% {
transform: translateY(0);
animation-timing-function: ease-out;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment