zank Posted December 1, 2023 Share Posted December 1, 2023 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 More sharing options...
Solution GSAP Helper Posted December 1, 2023 Solution Share Posted December 1, 2023 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: Try setting will-change: transform on the CSS of your moving elements. Make sure you're animating transforms (like x, y) instead of layout-affecting properties like top/left. Definitely avoid using CSS filters or things like blend modes. Those are crazy expensive for browsers to render. 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. 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) 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. 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. 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. 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 More sharing options...
zank Posted December 1, 2023 Author Share Posted December 1, 2023 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 More sharing options...
GSAP Helper Posted December 1, 2023 Share Posted December 1, 2023 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! 1 Link to comment Share on other sites More sharing options...
zank Posted December 1, 2023 Author Share Posted December 1, 2023 Thanks Link to comment Share on other sites More sharing options...
zank Posted December 28, 2023 Author Share Posted December 28, 2023 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> 1 1 Link to comment Share on other sites More sharing options...
Rodrigo Posted December 28, 2023 Share Posted December 28, 2023 Hi, Yeah, WebGL is a far better alternative on intensive animations like this, works really smooth great job! 🥳 Also I really like this part as well 🤩 https://dreamteamdesign.majestico.it/team/ Happy Tweening! 2 Link to comment Share on other sites More sharing options...
Recommended Posts
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 accountSign in
Already have an account? Sign in here.
Sign In Now