Jump to content
Search Community

GSAP Animation performance refactor

zank test
Moderator Tag

Go to solution Solved by GSAP Helper,

Recommended Posts

I've been working on a website for a customer, but I probably made the poor choiche of deciding to animate an svg for the hero section where i animated the position of 2 ellipse with a blur effect.

 

The performance are great on desktop but on mobile it barely even works causing lots of lags.

 

https://dreamteamdesign.majestico.it

 

 

I'm talking about the animation that start from the circle mask to the end of the animated gradient.

 

What would be the ideal way to animate this? Should I animate using canvas?

 

Do you know if there's an example of something similar?

 

Thanks

Link to comment
Share on other sites

  • Solution

Yeah, the way you set that up is EXTREMELY hard on the graphics rendering engine in browsers. Filters are just terrible. Totally unrelated to GSAP, of course. And then you also have to be careful about the overall bounding box of pixel changes between ticks. You've got a huge bounding box (way beyond the edges of the browser). 

 

A few ideas: 

  • Instead of ellipses with blurs applied, do the blur in Photoshop and save it as a PNG with transparency and animate that so that you're not asking the browser to dynamically do that blurring on every tick. Basically, pre-render that blur.
  • Or try using something like Pixi.js which leverages WebGL for much better rendering performance. Don't use SVG. 
  • Or use a video

Here are some other general performance tips: 

  1. Try setting will-change: transform on the CSS of your moving elements. 
  2. Make sure you're animating transforms (like x, y) instead of layout-affecting properties like top/left. 
  3. Definitely avoid using CSS filters or things like blend modes. Those are crazy expensive for browsers to render.
  4. Be very careful about using loading="lazy" on images because it forces the browser to load, process, rasterize and render images WHILE you're scrolling which is not good for performance. 
  5. Make sure you're not doing things on scroll that'd actually change/animate the size of the page itself (like animating the height property of an element in the document flow)
  6. Minimize the area of change. Imagine drawing a rectangle around the total area that pixels change on each tick - the bigger that rectangle, the harder it is on the browser to render. Again, this has nothing to do with GSAP - it's purely about graphics rendering in the browser. So be strategic about how you build your animations and try to keep the areas of change as small as you can.
  7. If you're animating individual parts of SVG graphics, that can be expensive for the browser to render. SVGs have to fabricate every pixel dynamically using math. If it's a static SVG that you're just moving around (the whole thing), that's fine - the browser can rasterize it and just shove those pixels around...but if the guts of an SVG is changing, that's a very different story. 
  8. data-lag is a rather expensive effect, FYI. Of course we optimize it as much as possible but the very nature of it is highly dynamic and requires a certain amount of processing to handle correctly.
  9. I'd recommend strategically disabling certain effects/animations and then reload it on your laptop and just see what difference it makes (if any). 

Ultimately there's no silver bullet, like "enable this one property and magically make a super complex, graphics-heavy site run perfectly smoothly even on 8 year old phones" :)

I hope this helps!

Link to comment
Share on other sites

Thanks for all the tips, I've tried removing the filter effect but still couldn't reach good performance, I will try to make the animation with Pixi.js as it seem the most responsive way of archiving the same result with good performance (maybe?)

 

 

Link to comment
Share on other sites

Maybe. Performance-wise, a video might actually be better but it's hard to know without running tests. Obviously that'd eat up more kb but the runtime performance might be better. 

 

If I were you, I'd try a few options and measure performance to see which does best. 

 

Good luck!

  • Thanks 1
Link to comment
Share on other sites

  • 4 weeks later...

Ended up using pixi js, I leave the code here if someone need, I ll make a codepen if I will have spare time.

 

<div class="background-effect flex items-center justify-center">
    <canvas class="orb-canvas"></canvas>
</div>
 
<style>
    .background-effect {
        position: fixed;
        width: 100dvw;
        height: 100dvh;
        z-index: -1;
    }
    .orb-canvas {
        position: fixed;
        top: 0;
        left: 0;
        width: 100dvw;
        height: 100dvh;
        pointer-events: none;
    }
</style>
 
<script>
    import * as PIXI from "pixi.js";
    import { gsap } from "gsap";
    import { ScrollTrigger } from "gsap/ScrollTrigger";
    import { createNoise2D } from "simplex-noise";
    import { KawaseBlurFilter } from "@pixi/filter-kawase-blur";
    import debounce from "debounce";
 
    gsap.registerPlugin(ScrollTrigger);
 
    let app: PIXI.Application<PIXI.ICanvas> | null = null;
 
    function init() {
        if (!document.getElementById("hero-section")) {
            return;
        }
 
        if (app) {
            app = null;
        }
 
        // return a random number within a range
        function random(min, max) {
            return Math.random() * (max - min) + min;
        }
 
        // map a number from 1 range to another
        function map(n, start1, end1, start2, end2) {
            return ((n - start1) / (end1 - start1)) * (end2 - start2) + start2;
        }
 
        // Create a new simplex noise instance
        const noise2D = new createNoise2D();
 
        // Orb class
        class Orb {
            // Pixi takes hex colors as hexidecimal literals (0x rather than a string with '#')
            constructor(fill = 0x000000, startAngle = 0) {
                // bounds = the area an orb is "allowed" to move within
                this.bounds = this.setBounds();
                // initialise the orb's { x, y } values to a random point within it's bounds
                this.x = random(this.bounds["x"].min, this.bounds["x"].max);
                this.y = random(this.bounds["y"].min, this.bounds["y"].max);
 
                // how large the orb is vs it's original radius (this will modulate over time)
                this.scale = 1;
 
                // what color is the orb?
                this.fill = fill;
 
                // the original radius of the orb, set relative to window height
                this.radius = random(window.innerHeight, window.innerHeight * 1.5);
 
                // starting points in "time" for the noise/self similar random values
                this.xOff = random(0, 1000);
                this.yOff = random(0, 1000);
                // how quickly the noise/self similar random values step through time
                this.inc = 0.0001;
 
                // PIXI.Graphics is used to draw 2d primitives (in this case a circle) to the canvas
                this.graphics = new PIXI.Graphics();
                this.graphics.alpha = 0.925;
 
                this.angle = startAngle ?? random(0, Math.PI * 2); // Random starting angle
 
                this.angularVelocity = 0.01;
 
                this.selfRotation = 0; // Initialize self-rotation angle
                this.selfRotationSpeed = 0.0015; // Speed of self-rotation
            }
 
            setBounds() {
                // how far from the { x, y } origin can each orb move
                const maxDist = window.innerWidth < 1000 ? window.innerWidth / 3 : window.innerWidth / 5;
                // the { x, y } origin for each orb (the bottom right of the screen)
                const originX = window.innerWidth / 1.25;
                const originY = window.innerWidth < 1000 ? window.innerHeight : window.innerHeight / 1.375;
 
                // allow each orb to move x distance away from it's x / y origin
                return {
                    x: {
                        min: originX - maxDist,
                        max: originX + maxDist,
                    },
                    y: {
                        min: originY - maxDist,
                        max: originY + maxDist,
                    },
                };
            }
 
            update() {
                // self similar "psuedo-random" or noise values at a given point in "time"
                const xNoise = noise2D(this.xOff, this.xOff);
                const yNoise = noise2D(this.yOff, this.yOff);
                const scaleNoise = noise2D(this.xOff, this.yOff);
 
                // map the xNoise/yNoise values (between -1 and 1) to a point within the orb's bounds
                this.x = map(xNoise, -1, 1, this.bounds["x"].min, this.bounds["x"].max);
                this.y = map(yNoise, -1, 1, this.bounds["y"].min, this.bounds["y"].max);
                // map scaleNoise (between -1 and 1) to a scale value somewhere between half of the orb's original size, and 100% of it's original size
                this.scale = map(scaleNoise, -1, 1, 0.5, 1.1);
 
                // step through "time"
                this.xOff += this.inc;
                this.yOff += this.inc;
 
                // Circular motion parameters
                const centerX = window.innerWidth / 2;
                const centerY = window.innerHeight / 2;
                const radius = window.innerWidth < 1000 ? window.innerHeight * 1.1 : window.innerHeight; // Adjust the radius as needed
 
                // Calculate new x and y based on the angle
                this.x = centerX + radius * Math.cos(this.angle);
                this.y = centerY + radius * Math.sin(this.angle);
 
                // Update the angle for the next frame
                this.angle += this.angularVelocity;
 
                // Normalize the angle to prevent overflow
                if (this.angle > Math.PI * 2) {
                    this.angle -= Math.PI * 2;
                }
 
                // Update the self-rotation angle
                this.selfRotation += this.selfRotationSpeed;
 
                // Normalize the self-rotation angle to prevent overflow
                if (this.selfRotation > Math.PI * 2) {
                    this.selfRotation -= Math.PI * 2;
                }
            }
 
            render() {
                // update the PIXI.Graphics position and scale values
                this.graphics.x = this.x;
                this.graphics.y = this.y;
                this.graphics.scale.set(this.scale);
                // Apply the self-rotation transformation
                this.graphics.rotation = this.selfRotation;
 
                // clear anything currently drawn to graphics
                this.graphics.clear();
 
                // tell graphics to fill any shapes drawn after this with the orb's fill color
                this.graphics.beginFill(this.fill);
                // draw a circle at { 0, 0 } with it's size set by this.radius
                this.graphics.drawEllipse(0, 0, this.radius, this.radius / 1.2);
                // let graphics know we won't be filling in any more shapes
                this.graphics.endFill();
            }
        }
 
        // Create PixiJS app
        app = new PIXI.Application({
            // render to <canvas class="orb-canvas"></canvas>
            view: document.querySelector(".orb-canvas"),
            // auto adjust size to fit the current window
            resizeTo: window,
            autoStart: false,
            // transparent background
            transparent: true,
            antialias: true,
        });
 
        // Function to render the scene
        function renderScene() {
            app.renderer.render(app.stage);
        }
 
        // Create a separate container for the mask
        const maskContainer = new PIXI.Container();
 
        // Create a container for orbs
        const orbContainer = new PIXI.Container();
 
        // Create a graphics object to act as the background
        const background = new PIXI.Graphics();
        background.beginFill(0x02fdfe); // Replace with your desired background color
        background.drawRect(0, 0, app.screen.width, app.screen.height);
        background.endFill();
 
        // Add the background to the container
        orbContainer.addChild(background);
 
        maskContainer.addChild(orbContainer);
 
        app.stage.addChild(maskContainer);
 
        orbContainer.filters = [new KawaseBlurFilter(50, 10, true)];
 
        // Set color to #f0a0c5 in exadecimal
        const color = 0xf0a0c5;
 
        // Create a circular mask
        const mask = new PIXI.Graphics();
 
        // Function to redraw the mask
        function redrawMask(newRadius) {
            mask.clear();
            mask.beginFill(0x02fdfe);
            mask.drawCircle(window.innerWidth / 2, window.innerHeight / 2 + window.innerHeight / 4, newRadius);
            mask.endFill();
        }
 
        // Initial radius of the mask
        const initialRadius = 45; // Set your initial radius
 
        // Object with a property for the radius
        const maskRadiusObj = { radius: initialRadius };
 
        maskContainer.addChild(mask); // Add the mask to the mask container
 
        const button = new PIXI.Graphics();
 
        // Function to redraw the button
        function redrawButton(newRadius: number) {
            button.clear();
            button.beginFill(0x000000);
            button.drawCircle(window.innerWidth / 2, window.innerHeight / 2 + window.innerHeight / 4, newRadius);
            button.endFill();
        }
 
        const buttonRadiusObj = { radius: initialRadius - 7 };
 
        maskContainer.addChild(button);
 
        function setupScrollTrigger() {
            gsap.timeline({
                defaults: { ease: "power1.inOut" },
                scrollTrigger: {
                    scrub: 0.1,
                    start: `500 top`,
                    end: `900 top`,
                    markers: false,
                    pinSpacing: false,
                },
            })
                .fromTo(
                    maskRadiusObj,
                    {
                        radius: maskRadiusObj.radius, // Radius to fill the screen
                    },
                    {
                        radius: Math.max(window.innerWidth, window.innerHeight), // Radius to fill the screen
                        onUpdate: () => {
                            redrawMask(maskRadiusObj.radius);
                            renderScene(); // Call renderScene on radius update
                        },
                    },
                )
                .fromTo(
                    buttonRadiusObj,
                    {
                        radius: buttonRadiusObj.radius,
                    },
                    {
                        radius: 0,
                        onUpdate: () => {
                            redrawButton(buttonRadiusObj.radius);
                            renderScene(); // Call renderScene on radius update
                        },
                    },
                    "<",
                );
        }
 
        // GSAP animation to expand the radius
        setTimeout(function () {
            if (window.scrollY <= 50) {
                // Initial animation timeline
                let initialTimeline = gsap
                    .timeline({ defaults: { duration: 0.8, ease: "back.out(1.4)" } })
                    .fromTo(
                        maskRadiusObj,
                        {
                            radius: 0,
                        },
                        {
                            radius: initialRadius, // Radius to fill the screen
                            onUpdate: () => {
                                redrawMask(maskRadiusObj.radius);
                                renderScene(); // Call renderScene on mask update
                            },
                        },
                    )
                    .fromTo(
                        buttonRadiusObj,
                        {
                            radius: 0,
                        },
                        {
                            radius: initialRadius - 7,
                            onUpdate: () => {
                                redrawButton(buttonRadiusObj.radius);
                                renderScene(); // Call renderScene on button update
                            },
                        },
                        ">-=0.5",
                    )
                    .then(() => {
                        setupScrollTrigger();
                    });
            } else {
                // Directly setup ScrollTrigger if scroll is more than 100
                setupScrollTrigger();
            }
        }, 50);
 
        // Create orbs
        const orb1 = new Orb(color, 0); // First orb starts at angle 0
        const orb2 = new Orb(color, Math.PI); // Second orb starts 180 degrees (π radians) opposite
 
        orbContainer.addChild(orb1.graphics);
        orbContainer.addChild(orb2.graphics);
 
        // Apply the mask to the stage
        app.stage.mask = mask;
 
        // Function containing the ticker logic
        function renderOrbs() {
            orb1.update();
            orb1.render();
            orb2.update();
            orb2.render();
            renderScene();
        }
 
        renderOrbs();
 
        const fps = 30
        const fpsInterval = 1000 / fps;
        let then = Date.now();
 
        function startAnimationLoop() {
            requestAnimationFrame(startAnimationLoop);
 
            const now = Date.now();
            const elapsed = now - then;
 
            if (elapsed > fpsInterval) {
                // Adjust for specified fps
                then = now - (elapsed % fpsInterval);
 
                renderOrbs(); // Render your scene
            }
        }
 
        // Flag to indicate if the ticker has started
        let tickerStarted = false;
 
        // Named function for the scroll event
        function handleScroll() {
            if (window.scrollY > window.innerHeight / 1.1 && !tickerStarted) {
                startAnimationLoop();
                tickerStarted = true;
                // Remove the event listener
                window.removeEventListener("scroll", handleScroll);
            }
        }
 
        // Scroll event listener
        // Add the scroll event listener
        window.addEventListener("scroll", handleScroll);
    }
 
    document.removeEventListener("DOMContentLoaded", init); // astro:page-load
    document.addEventListener("DOMContentLoaded", init); // astro:page-load
</script>
  • Like 1
  • Thanks 1
Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...