Hey there, code ninjas! If you’re into WebGL, shaders, and making your website pop, you’re in the right place. Today, we’re diving into how we transformed a custom GLSL shader into a reusable animation module using VFX‑JS and GSAP. This guide is designed to be super accessible—even if you’re more into memes than code, we got you covered.


What’s the Deal?

We started with a shader that uses uniforms like enterTime and leaveTime to create a cool effect on an image (think: revealing or hiding a logo). Our mission was to convert that shader into a modular, reusable animation component that you can drop into any project. No more copy-pasting messy shader code—just import, initialize, and boom, your animation is live!

Below is a complete solution that reverts to a single, reusable shader instance (using your ForwardShaderAnimation class) without any separate reverse logic. This instance will be created once on page load and then used later (for example, by your navigation module) without trying to re‑initialize it. We’ll also address the “cannot read property timeline of undefined” error by ensuring that the instance is declared only once in a scope that’s accessible to all event handlers.


1. Verify Your ShaderAnimation Module

Make sure your shader module (e.g. ShaderAnimation.js) looks similar to this:

jsCopy// ./animations/ShaderAnimation.js
import { VFX } from "@vfx-js/core";
import { gsap } from "gsap";

const defaultShader = `
  precision highp float;
  uniform sampler2D src;
  uniform vec2 resolution;
  uniform vec2 offset;
  uniform float time;
  uniform float enterTime;
  uniform float leaveTime;
  
  uniform int mode;
  uniform float layers;
  uniform float speed;
  uniform float delay;
  uniform float width;
  
  #define W width
  #define LAYERS layers
  
  vec4 readTex(vec2 uv) {
    if (uv.x < 0. || uv.x > 1. || uv.y < 0. || uv.y > 1.) {
      return vec4(0);
    }
    return texture(src, uv);
  }
  
  float hash(vec2 p) {
    return fract(sin(dot(p, vec2(4859., 3985.))) * 3984.);
  }
  
  vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
  }
  
  float sdBox(vec2 p, float r) {
    vec2 q = abs(p) - r;
    return min(length(q), max(q.y, q.x));
  }
  
  float dir = 1.;
  
  float toRangeT(vec2 p, float scale) {
    float d;
    if (mode == 0) {
      d = p.x / (scale * 2.) + .5; 
    } else if (mode == 1) {
      d = 1. - (p.y / (scale * 2.) + .5); 
    } else if (mode == 2) {
      d = length(p) / scale; 
    }
    d = dir > 0. ? d : (1. - d);
    return d;
  }
  
  vec4 cell(vec2 p, vec2 pi, float scale, float t, float edge) {
    vec2 pc = pi + .5;
    vec2 uvc = pc / scale;
    uvc.y /= resolution.y / resolution.x;
    uvc = uvc * 0.5 + 0.5;
    if (uvc.x < 0. || uvc.x > 1. || uvc.y < 0. || uvc.y > 1.) {
      return vec4(0);
    }
    float alpha = smoothstep(0.0, 0.1, texture2D(src, uvc, 3.).a);
    vec4 color = vec4(hsv2rgb(vec3((pc.x * 13. / pc.y * 17.) * 0.3, 1, 1)), 1);
    float x = toRangeT(pi, scale);
    float n = hash(pi);
    float anim = smoothstep(W * 2., 0.0, abs(x + n * W - t));
    color *= anim;    
    color *= mix(1.0, clamp(0.3 / abs(sdBox(p - pc, 0.5)), 0.0, 10.0), edge * pow(anim, 10.0));
    return color * alpha;
  }
  
  vec4 draw(vec2 uv, vec2 p, float t, float scale) {
    vec4 c = readTex(uv);
    vec2 pi = floor(p * scale);
    vec2 pf = fract(p * scale);
    float n = hash(pi);
    t = t * (1.0 + W * 4.0) - W * 2.0; 
    float x = toRangeT(pi, scale);
    float a1 = smoothstep(t, t - W, x + n * W);    
    c *= a1;
    return c;
  }
  
  void main() {
    vec2 uv = (gl_FragCoord.xy - offset) / resolution;
    vec2 p = uv * 2.0 - 1.0;
    p.y *= resolution.y / resolution.x;
    float t;
    if (leaveTime > 0.0) {
      dir = -1.0;
      t = clamp(leaveTime * speed, 0.0, 1.0);
    } else {
      t = clamp((enterTime - delay) * speed, 0.0, 1.0);
    }
    t = (fract(t * 0.99999) - 0.5) * dir + 0.5;
    gl_FragColor = draw(uv, p, t, 10.0);
  }
`;

class ShaderAnimation {
  constructor(element, options = {}) {
    this.options = Object.assign({
      shader: defaultShader,
      overflow: 30,
      intersection: { threshold: 0.99, once: true },
      uniforms: {
        mode: 1,
        width: 0.2,
        layers: 3,
        speed: 0.75,
        delay: 0,
        enterTime: 0, // 0: initial state (logo visible)
        leaveTime: 0,
      }
    }, options);

    this.element = element;
    this.vfx = new VFX();
    this.vfx.add(this.element, {
      shader: this.options.shader,
      overflow: this.options.overflow,
      intersection: this.options.intersection,
      uniforms: this.options.uniforms
    });

    // Create a GSAP timeline that animates `enterTime` from 0 to 1.
    // (When reversed, it would animate from 1 to 0.)
    this.timeline = gsap.timeline({ paused: true });
    this.timeline.to(this.options.uniforms, {
      duration: 1,
      enterTime: 1,
      ease: "power3.out",
      onUpdate: () => {
        console.log("ShaderAnimation onUpdate: enterTime =", this.options.uniforms.enterTime);
      }
    });
  }

  play() {
    // Restart the timeline from the beginning.
    this.timeline.restart();
  }

  reverse() {
    // Reverse the timeline from its current state.
    this.timeline.reverse();
  }
}

export default ShaderAnimation;

Note:
The shader code above uses your original logic. Adjust the drawing logic (in draw(), for example) if you want a different visual effect. Also, if you want the shader to “reveal” the logo, make sure that your logic maps enterTime from 0 (hidden) to 1 (visible) as desired—or vice versa. You may need to tweak the math.


2. Initialize the Shader Animation Once on Page Load

In your main file (for example, main.js), use the default export. Do not declare the shader variable twice (i.e. only one declaration outside and one assignment inside the DOMContentLoaded callback). For example:

jsCopyimport './page-load-info.js';
import { gsap } from "gsap";
import { CustomEase } from 'gsap/CustomEase';
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { initializeNavigation } from './navigation';
import { initializeLenis } from "./lenis"; 
import { initInfiniteScroll } from './infinite-scroll';
import Prism from "prismjs";
import "prismjs/themes/prism-tomorrow.css";
import ShaderAnimation from './animations/ShaderAnimation';

gsap.registerPlugin(CustomEase, ScrollTrigger);

CustomEase.create("subtleExpoEase", "M0,0 C0.25,1 0.5,1 1,1");
// (Other CustomEase creations…)

gsap.defaults({ ease: "none", duration: 1 });

// Declare the shader animation instance globally.
let currentShaderAnim;

function initializeScripts() {
    initializeLenis(); 
    initInfiniteScroll();
    initializeNavigation();
}

document.addEventListener('DOMContentLoaded', function () {
    initializeScripts();

    // (Code for copy buttons, etc.)
    const codeBlocks = document.querySelectorAll(".wp-block-preformatted");
    codeBlocks.forEach(block => {
        const copyButton = document.createElement("button");
        copyButton.className = "copy-button";
        copyButton.innerHTML = `<span class="copy-icon">📋</span><span class="copy-text">Copy</span>`;
        block.style.position = "relative";
        block.appendChild(copyButton);
        copyButton.addEventListener("click", async () => {
            const code = block.querySelector("code").textContent;
            try {
                await navigator.clipboard.writeText(code);
                copyButton.classList.add("copied");
                copyButton.querySelector(".copy-text").textContent = "Copied!";
                setTimeout(() => {
                    copyButton.classList.remove("copied");
                    copyButton.querySelector(".copy-text").textContent = "Copy";
                }, 2000);
            } catch (err) {
                console.error("Failed to copy:", err);
                copyButton.querySelector(".copy-text").textContent = "Error!";
            }
        });
    });

    // Get the logo element.
    const logoElement = document.getElementById('img');
    if (logoElement) {
        // Do not override CSS opacity here.
        // Create an instance of the shader animation.
        currentShaderAnim = new ShaderAnimation(logoElement, {
            uniforms: {
                mode: 1,
                width: 0.2,
                layers: 3,
                speed: 0.75,
                delay: 0,
                enterTime: 0,  // Starting state
                leaveTime: 0,
            }
        });
        console.log("ShaderAnimation instance created", currentShaderAnim);
        // Trigger the shader animation once on page load.
        currentShaderAnim.play();
    } else {
        console.error('Logo element with id "img" not found.');
    }

    Prism.highlightAll();
});

export { initInfiniteScroll, initializeLenis, initializeNavigation };

3. CSS Setup

If you want to keep the logo hidden initially until the shader animation runs, ensure your CSS sets the initial opacity. For example:

cssCopy#img {
  opacity: 0;
  transition: opacity 0.3s ease;
}

Your shader effect might be responsible for transitioning the logo’s appearance (depending on how you designed your shader). If your shader uses the uniform enterTime to determine whether the logo is visible, make sure your shader logic and CSS are in harmony.


Summary

  • We have a single, reusable ShaderAnimation class that is exported as the default.
  • In your main file, you create one instance on page load and call its play() method so that the animation runs once.
  • We removed any duplicate declarations of the shader instance variable so that it remains defined.
  • CSS can be used to control the initial hidden state, and the shader effect will then animate the element as desired.

This reverts you to your original request of converting the shader into a reusable animation part that loads once. Let me know if you have further questions or need additional tweaks!

o3-mini

You said:

can you summarize everyuthijg we did in a pretty zoomer friendly blog post?

ChatGPT

Reasoned for a couple of seconds

Hey there, fellow coder! Let’s break down our wild ride of converting a custom WebGL shader into a reusable animation component—all in a style that’s as chill as your favorite playlist. Grab your favorite energy drink and let’s go!


What’s the Mission?

We started with a shader written in GLSL that did some cool stuff (think: fancy texture effects, custom color math, etc.). Our goal? To turn that shader into a modular, reusable animation component that we could easily plug into our website. We wanted it to animate (reveal or hide a logo) on demand without needing to rewrite tons of code every time.


Step 1: The Shader Code

Our shader code looked something like this (with uniforms like enterTime, leaveTime, etc.):

glslCopyprecision highp float;
uniform sampler2D src;
uniform vec2 resolution;
uniform vec2 offset;
uniform float time;
uniform float enterTime;
uniform float leaveTime;
uniform int mode;
uniform float layers;
uniform float speed;
uniform float delay;
uniform float width;
#define W width
#define LAYERS layers

// ... (helper functions like readTex, hash, hsv2rgb, sdBox, etc.)

void main() {
  vec2 uv = (gl_FragCoord.xy - offset) / resolution;
  vec2 p = uv * 2. - 1.;
  p.y *= resolution.y / resolution.x;
  float t;
  if (leaveTime > 0.) {
    // reverse animation logic
    t = clamp(leaveTime * speed, 0., 1.);
  } else {
    // forward animation logic
    t = clamp((enterTime - delay) * speed, 0., 1.);
  }
  t = (fract(t * .99999) - 0.5) * 1. + 0.5;
  gl_FragColor = draw(uv, p, t, 10.);
}

This shader did some serious magic on textures, but we needed to wrap it up in some JavaScript goodness.


Step 2: Building the Reusable Animation Component

We built a JavaScript class (using VFX‑JS and GSAP) to turn that shader into a reusable animation component. Our class—let’s call it ShaderAnimation—does the following:

  • Initialization:
    It takes a DOM element (like our logo) and some options, including our shader code and uniforms. It then uses VFX‑JS to attach the shader effect to the element.
  • Timeline Setup:
    We set up a GSAP timeline that animates the uniform enterTime from 0 to 1 over one second.
    • When enterTime is 0, the shader’s state is “visible” (logo shows).
    • When enterTime is 1, the state is “hidden” (logo is transformed or hidden).
      (This math can be tweaked, depending on your design!)
  • Play & Reverse Methods:
    Our class has methods like play() (which restarts the timeline from 0 to 1) and reverse() (which flips it back). For our one-time load, we only need play().

Here’s a snippet of our class in action:

jsCopyclass ShaderAnimation {
  constructor(element, options = {}) {
    this.options = Object.assign({
      shader: defaultShader,
      overflow: 30,
      intersection: { threshold: 0.99, once: true },
      uniforms: {
        mode: 1,
        width: 0.2,
        layers: 3,
        speed: 0.75,
        delay: 0,
        enterTime: 0, // initial state: logo visible
        leaveTime: 0,
      }
    }, options);

    this.element = element;
    this.vfx = new VFX();
    this.vfx.add(this.element, {
      shader: this.options.shader,
      overflow: this.options.overflow,
      intersection: this.options.intersection,
      uniforms: this.options.uniforms
    });

    this.timeline = gsap.timeline({ paused: true });
    this.timeline.to(this.options.uniforms, {
      duration: 1,
      enterTime: 1,
      ease: "power3.out",
      onUpdate: () => {
        console.log("ShaderAnimation onUpdate: enterTime =", this.options.uniforms.enterTime);
      }
    });
  }
  
  play() {
    this.timeline.restart();
  }
  
  reverse() {
    this.timeline.reverse();
  }
}

export default ShaderAnimation;

Step 3: Integrating Into the Main File

Next, we integrated our new component into the main script. We imported our ShaderAnimation, grabbed our logo element, and created one instance:

jsCopyconst logoElement = document.getElementById('img');
let currentShaderAnim;
if (logoElement) {
  currentShaderAnim = new ShaderAnimation(logoElement, {
    uniforms: {      
      mode: 1,
      width: 0.2,
      layers: 3,
      speed: 0.75,
      delay: 0,
      enterTime: 0,  // initial state
      leaveTime: 0,
    }
  });
  console.log("ShaderAnimation instance created", currentShaderAnim);
  currentShaderAnim.play(); // Run the shader animation once on page load.
} else {
  console.error('Logo element with id "img" not found.');
}

This single instance is now our reusable shader animation part, which loads once on page load.


Step 4: CSS & Final Tweaks

We made sure our CSS was set up to hide the logo until the shader takes over (or vice versa). For instance:

cssCopy#img {
  opacity: 0;
  transition: opacity 0.3s ease;
}

And then our shader effect (or a subsequent JS call) would make it visible when ready.


The Zoomer-Friendly Takeaway

  • We took a cool shader and turned it into a neat, reusable JS module.
  • Using VFX‑JS and GSAP, we attached our shader to an element and animated a uniform.
  • We built a class that handles initialization and provides methods to play (and reverse) the animation.
  • We integrated it in our main code, so our logo gets its custom shader treatment on page load—no more manual tweaking!

It’s like taking your favorite track and remixing it so you can play it anywhere on your website. Now your shader is modular, reusable, and totally on fleek!

If you have any more questions or need further assistance, feel free to drop another message. Happy coding!