1
\$\begingroup\$

I'm recently working on a project that involves animation in the background. It works well on desktop but performance drops drastically on mobile. I'm using Paper.js to import a svg and animate it. This is the demo.

paper.project.importSVG(svg, function(item) {
          var moveSpeed = 70;
          var movementRadius = 15;
          var boundingRec = item.bounds;
          var lines = [];
          var circles = [];
          /*
            Arrange lines and circles into different array
          */
          $.each(item.getItems({
            recursive: true,
          }), function(index, item) {
            if (item instanceof paper.Shape && item.type == 'circle') {
              item.data.connectedSegments = [];
              item.data.originalPosition = item.position;
              circles.push(item);
            }
            if (item instanceof paper.Path) {
              lines.push(item);
            }
          });

          /*
            Loop through all paths
            Checks if any segment points is within circles
            Anchors the point to the circle if within
          */
          $.each(lines, function(pathIndex, path) {
            $.each(path.segments, function(segmentIndex, segment) {
              $.each(circles, function(circleIndex, circle) {
                if (circle.contains(segment.point)) {
                  circle.data.connectedSegments.push( segment.point );
                  return false;
                }
              });
            });
          });

          /*
            Animate the circles
          */
          $.each(circles, function(circleIndex, circle) {
            var originalPosition = circle.data.originalPosition;
            var radius = circle.radius * movementRadius;

            var destination = originalPosition.add( paper.Point.random().multiply(radius) );

            circle.onFrame = function() {
              while (!destination.isInside(boundingRec)) {
                destination = originalPosition.add( paper.Point.random().multiply(radius) );
              }

              var vector = destination.subtract(circle.position);
              circle.position = circle.position.add(vector.divide(moveSpeed));
              // move connected segments based on circle

              for (var i = 0; i < circle.data.connectedSegments.length; i++) {
                circle.data.connectedSegments[i].set({
                    x: circle.position.x,
                    y: circle.position.y,
                });
              }

              if (vector.length < 5) {
                destination = originalPosition.add( paper.Point.random().multiply(radius) );
              }
            }
          });
        });

A single SVG animation alone is causing the CPU usage to go up to 90% on mid-tier mobile devices and it go to the point that the interface becomes unusable. Any advice or help is much appreciated.

\$\endgroup\$
1
\$\begingroup\$

SVG for animation is Bad :(

SVG is a very difficult medium to use for animation as it can incur huge overheads when you use seamingly common sense structure.

I must also point out that many of the SVG frameworks such as Paper.js add to the problem with poor code and an apparent indifference to the need to create performant interfaces.

To review your code

As this is a review I must review your code.

  • There is no need to use jQuery as the standard DOM API's do it all and much faster.

  • Use constant declarations for variables that don't change, eg var lines = []; should be const lines = []; and var moveSpeed = 70 should be const moveSpeed = 70;

  • You have a random search for each point to test if it is inside the bounds. If you have a point outside the bounds by a distance greater than the radius, this loop may run forever trying to find a random point that is inside the bounds.

    It is non deterministic search, with a worst case complexity of O(Infinity) (something that computers just do not do well LOL)

    while (!destination.isInside(boundingRec)) { destination = originalPosition.add(paper.Point.random().multiply(radius)); }

    A much better deterministic approach is to test for the bounds, and if not in bounds find the closest point that is and set the point to that. this reduces the worst case complexity to O(1) which computers do very well. (see example code)

Apart from that your code is well written.


Paper.js

I did first write this answer assuming that the content was all SVG but a second look and I see that you are rendering SVG to a canvas via paper.js.

I personal think paper.js is a slow and poorly coded framework. Its core (low level) functions are bloated with overheads that far exceed the time to perform the basic functions purpose.

Rather than list the miles of overhead you add using paper.js I just compared your code to a rewrite without frameworks and using the canvas only avoiding SVG as a image source.

I then compared the run time via chrome's performance recorder in dev tools.

The code using paper.js took 6.89ms to render a frame.

The rewrite took 0.53ms to do the same.

Canvas size

I dont know how you are sizing the canvas for the handheld devices, but make sure that they match the screen resolution and do not use a large canvas that you then size to fit as you can seriously kill performance that way.

The canvas must not be larger than as follows or you use too much RAM and end up rendering pixels that are not seen.

canvas.width = innerWidth;
canvas.height = innerHeight;

Rewrite

So I will just go over the rewrite.

For your code there are 5 basic parts

  1. Define the points and lines
  2. Move the points
  3. Render the lines
  4. Render the circles
  5. Animate and present the content

Define the points

As we are not going to use the SVG we need to define the points in javascript.

I have extracted the circles (AKA verts) I am not going to process the data you have and just assume that lines are between a vert and the 6 closest verts. Thus we define the verts and create a function to find the lines.

const numberLines = 6;  // Number of lines per circle
const verts = [ 
    {id :1 , x: 30.7,  y: 229.2 },
    {id :2 , x: 214.4, y: 219.6},
    {id :3 , x: 278.4, y: 186.4},
    {id :4 , x: 382.5, y: 132.5},
    {id :5 , x: 346.8, y: 82   },
    {id :6 , x: 387.9, y: 6.7  },
    {id :7 , x: 451.8, y: 60.8 },
    {id :8 , x: 537.0, y: 119.9},
    {id :9 , x: 545.1, y: 119.9},
    {id :9 , x: 403.5, y: 122.1},
    {id :10 , x: 416.3, y: 130  },
    {id :11 , x: 402.6, y: 221.4},
    {id :12 , x: 409.9, y: 266.4},
    {id :13 , x: 437.1, y: 266.8},
    {id :14 , x: 478.1, y: 269.6},
    {id :15 , x: 242.6, y: 306.1},
    {id :16 , x: 364.0, y: 267  },
    {id :17 , x: 379.1, y: 310.7},
    {id :18 , x: 451.2, y: 398.9},
    {id :19 , x: 529.6, y: 377.9},
    {id :20 , x: 644.8, y: 478.3},
    {id :21 , x: 328.3, y: 324.5},
    {id :22 , x: 314.4, y: 364.3},
    {id :23 , x: 110.2, y: 327.8},
    {id :24 , x: 299.1, y: 219.6},
    {id :25 , x: 130.4, y: 218.1},
    {id :26 , x: 307.4, y: 298.4},
    {id :27 , x: 431.3, y: 360.1},
    {id :28 , x: 551.7, y: 414.4},
    {id :29 , x: 382.5, y: 239.7},
];
const line = (p1, p2) => ({p1, p2});
var lines = new Map();  // is var as this is replaced with an array after finding near verts
function findClosestVertInDist(vert,min, max, result = {}) {
    const x = vert.x, y = vert.y;
    result.minDist = max;
    result.closest = undefined;
    for (const v of verts) {    
        const dx = v.x - x;
        const dy = v.y - y;
        const dist = (dx * dx + dy * dy) ** 0.5;
        if (dist > min && dist < result.minDist) {
            result.minDist = dist;
            result.closest = v;
        }
    }
    return result;
}
// this is a brute force solution.     
function createLines() {
    var hash;
    lines.length = 0; 
    const mod2Id = verts.length; // to get unique hash for a line
    const closeVert = {}
    for (const v of verts) {    
        closeVert.minDist = 0;
        for (let i = 0; i < numberLines; i++) {
            findClosestVertInDist(v, closeVert.minDist, Infinity, closeVert);
            if(closeVert.closest) { // if you have less than 6 verts you need this test
                if (v.id < closeVert.closest.id) {
                    hash = closeVert.closest.id * mod2Id + v.id;
                } else {
                    hash = closeVert.closest.id + v.id * mod2Id;
                }
                lines.set(hash,line(v,closeVert.closest));
            } else {
                i--; 
            }
        }
    }
    lines = [...lines.values()]; // Dont need the map so replace  with array of lines
    verts.forEach(v => {    // verts dont need the id but need an origin so add 
                             // the relevant data
        v.ox = v.x;  // the verts origin
        v.oy = v.y;
        v.dx = v.x;  // the destination to move to
        v.dy = v.y;
        v.moveSpeed = Math.random() * (moveSpeedMax - moveSpeedMin) + moveSpeedMin;
        v.move = 1; // unit value how far vert has moved to new point,
                    // 0 is at start, 1 is at destination
        delete v.id; // remove the id
    }); 

}
createLines();

After this is run you get a data structure very similar to the SVG. A set of points verts and a set of lines lines that reference points.

Rendering

The canvas 2D API is very easy to use and has functions to draw lines and circles. It can render content as paths (very similar to SVG path element) and uses the GPU and is just as fast (if not faster on some browsers than the SVG renderer)

So to create the element and render to it we need the following

// Script is placed after the canvas element via load event, 

// the 2D API called context or abbreviated as ctx
// You may want to query the DOM with document.getElementById("canvas") but not needed
const ctx = canvas.getContext("2D"); 
canvas.width = innerWidth;
canvas.height = innerHeight;

Math.PI2 = Math.PI * 2; // create a 360 radians constant
// Define the styles
const lineStyle = {
    lineWidth : 1,
    strokeStyle : "#FFFFFF88",
}
const circleStyle = {
    fillStyle : "cyan",
}
const circleRadius = 2.5;
const moveDist = 70;
// min and max vert speeds so points donty all change direction at once.
const moveSpeedMax = 1 / 120; // unit speed (at 60fps this moves to destination in two seconds)
const moveSpeedMin = 1 / 240; // unit speed (at 60fps this moves to destination in four seconds)




function drawLines(ctx, lines, style) { // ctx where to draw, lines what to draw
    Object.assign(ctx, style);
    // start a new 2D path 
    ctx.beginPath();
    for (const line of lines) {
        ctx.moveTo(line.p1.x, line.p1.y);
        ctx.lineTo(line.p2.x, line.p2.y);
    }
    // the path has been defined so render it in one go.
    ctx.stroke();
}
function drawCircles(ctx, verts, radius, style) { // ctx where to draw, verts what to draw
                                            // radius (say no more)
                                            // and style
    Object.assign(ctx, style);
    ctx.beginPath();
    for (const vert of verts) {
        // to prevent arcs connecting you need to move to the arc start point        
        ctx.moveTo(vert.x + radius, vert.y);
        ctx.arc(vert.x, vert.y, radius, 0, Math.PI2);
    }
    // the path has been defined so render it in one go.
    ctx.fill();
}

                
<!-- replaces the SVG element -->
<canvas id="canvas" width = "642" height = "481"></canvas>

Animation

As we are animating the content we need to make sure that what we render is presented correctly and in sync with the display. All browsers provide a special callback event via requestAnimationFrame that lets you make changes to the DOM that will only be presented on the next display refresh.

requestAnimationFrame(update); // starts the animation
function update(){
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    animateCircles(verts, ctx.canvas); // animate the verts
    drawLines(ctx, lines, lineStyle);
    drawCircles(ctx, verts, circleRadius, circleStyle);

    // All done request the next frame
    requestAnimationFrame(update); 
}

function animateCircles(verts, canvas){
    for(const vert of verts){
        vert.move += vert.moveSpeed;
        if (vert.move >= 1) { // point at dest so find a new random point
            // using polar coords to randomly move a point 
            const dir = Math.random() * Math.PI2;  
            const dist = Math.random() * moveDist;
            vert.ox = vert.dx; // set new origin
            vert.oy = vert.dy;  
            let x = vert.ox + Math.cos(dir) * dist;
            let y = vert.oy + Math.sin(dir) * dist;

            // bounds check
            if (x < circleRadius) { x = circleRadius }
            else if (x >= canvas.width - circleRadius) { x = canvas.width - circleRadius }
            if (y < circleRadius) { y = circleRadius }
            else if (y >= canvas.height - circleRadius) { y = canvas.height - circleRadius }

            // point is in bounds and within dist of origin so set its new destination
            vert.dx = x;
            vert.dy = y;
            vert.move = 0; // set ubit dist moved.

        }

        vert.x = (vert.dx - vert.ox) * vert.move + vert.ox;
        vert.y = (vert.dy - vert.oy) * vert.move + vert.oy;

    }
}

So to make you code work using the canvas I have replaced the onframe events for a single function that handles all the circles in one pass, and added a better bounds check that uses the canvas size to check circles are inside.

Put it all together

So now putting all the above into a working snippet we have side stepped the SVG elements and improved the code by handling the array of verts and lines as single entities.

We have also reduced the workload and RAM needs of the page, as we have only one layer (the canvas) and one composite operation (which can also be avoided on some browsers)

const ctx = canvas.getContext("2d"); 
canvas.width = innerWidth;
canvas.height = innerHeight;
Math.PI2 = Math.PI * 2; 
const lineStyle = {
    lineWidth : 1,
    strokeStyle : "#FF000055",
};
const circleStyle = {
    fillStyle : "blue",
};
const circleRadius = 2.5;
const moveDist = 70;
const moveSpeedMax = 1 / 120; 
const moveSpeedMin = 1 / 240; 

const numberLines = 6;
const verts = [ 
    {id :1 , x: 30.7,  y: 229.2 },
    {id :2 , x: 214.4, y: 219.6},
    {id :3 , x: 278.4, y: 186.4},
    {id :4 , x: 382.5, y: 132.5},
    {id :5 , x: 346.8, y: 82   },
    {id :6 , x: 387.9, y: 6.7  },
    {id :7 , x: 451.8, y: 60.8 },
    {id :8 , x: 537.0, y: 119.9},
    {id :9 , x: 545.1, y: 119.9},
    {id :9 , x: 403.5, y: 122.1},
    {id :10 , x: 416.3, y: 130  },
    {id :11 , x: 402.6, y: 221.4},
    {id :12 , x: 409.9, y: 266.4},
    {id :13 , x: 437.1, y: 266.8},
    {id :14 , x: 478.1, y: 269.6},
    {id :15 , x: 242.6, y: 306.1},
    {id :16 , x: 364.0, y: 267  },
    {id :17 , x: 379.1, y: 310.7},
    {id :18 , x: 451.2, y: 398.9},
    {id :19 , x: 529.6, y: 377.9},
    {id :20 , x: 644.8, y: 478.3},
    {id :21 , x: 328.3, y: 324.5},
    {id :22 , x: 314.4, y: 364.3},
    {id :23 , x: 110.2, y: 327.8},
    {id :24 , x: 299.1, y: 219.6},
    {id :25 , x: 130.4, y: 218.1},
    {id :26 , x: 307.4, y: 298.4},
    {id :27 , x: 431.3, y: 360.1},
    {id :28 , x: 551.7, y: 414.4},
    {id :29 , x: 382.5, y: 239.7},
];
const line = (p1, p2) => ({p1, p2});
var lines = new Map(); 
function findClosestVertInDist(vert,min, max, result = {}) {
    const x = vert.x, y = vert.y;
    result.minDist = max;
    result.closest = undefined;
    for (const v of verts) {    
        const dx = v.x - x;
        const dy = v.y - y;
        const dist = (dx * dx + dy * dy) ** 0.5;
        if(dist > min && dist < result.minDist) {
            result.minDist = dist;
            result.closest = v;
        }
    }
    return result;
}

function createLines() {
    var hash;
    lines.length = 0; 
    const mod2Id = verts.length; 
    const closeVert = {}
    for (const v of verts) {    
        closeVert.minDist = 0;
        for (let i = 0; i < numberLines; i++) {
            findClosestVertInDist(v, closeVert.minDist, Infinity, closeVert);
            if (closeVert.closest) { 
                if (v.id < closeVert.closest.id) {
                    hash = closeVert.closest.id * mod2Id + v.id;
                } else {
                    hash = closeVert.closest.id + v.id * mod2Id;
                }
                lines.set(hash,line(v,closeVert.closest));
            } else {
                i--; 
            }
        }
    }
    lines = [...lines.values()]; 
    verts.forEach(v => {  
        v.ox = v.x; 
        v.oy = v.y;
        v.dx = v.x;
        v.dy = v.y;
        v.moveSpeed = Math.random() * (moveSpeedMax - moveSpeedMin) + moveSpeedMin;
        v.move = 1; 
        delete v.id; 
    }); 

}
createLines();

function drawLines(ctx, lines, style) { 
    Object.assign(ctx, style);
    ctx.beginPath();
    for (const line of lines) {
        ctx.moveTo(line.p1.x, line.p1.y);
        ctx.lineTo(line.p2.x, line.p2.y);
    }
    ctx.stroke();
}
function drawCircles(ctx, verts, radius, style) { 
    Object.assign(ctx, style);
    ctx.beginPath();
    for (const vert of verts) {        
        ctx.moveTo(vert.x + radius, vert.y);
        ctx.arc(vert.x, vert.y, radius, 0, Math.PI2);
    }
    ctx.fill();
}


requestAnimationFrame(update); // starts the animation
function update(){
    // to check is resized
    if (canvas.width !== innerWidth || canvas.height !== innerHeight) {
        canvas.width = innerWidth;
        canvas.height = innerHeight;
    } else {  // resize clears the canvas so I use else here
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    }
    animateCircles(verts, ctx.canvas); 
    drawLines(ctx, lines, lineStyle);
    drawCircles(ctx, verts, circleRadius, circleStyle);
    requestAnimationFrame(update); 
}

function animateCircles(verts, canvas){
    for(const vert of verts){
        vert.move += vert.moveSpeed;
        if (vert.move >= 1) { 
            const dir = Math.random() * Math.PI2;  
            const dist = Math.random() * moveDist;
            vert.ox = vert.dx; 
            vert.oy = vert.dy;  
            let x = vert.ox + Math.cos(dir) * dist;
            let y = vert.oy + Math.sin(dir) * dist;

            if (x < circleRadius) { x = circleRadius }
            else if (x >= canvas.width - circleRadius) { x = canvas.width - circleRadius }
            if (y < circleRadius) { y = circleRadius }
            else if (y >= canvas.height - circleRadius) { y = canvas.height - circleRadius }

            vert.dx = x;
            vert.dy = y;
            vert.move = 0; 
        }
        vert.x = (vert.dx - vert.ox) * vert.move + vert.ox;
        vert.y = (vert.dy - vert.oy) * vert.move + vert.oy;
    }
}
<!-- replaces the SVG element -->
<canvas id="canvas" width = "642" height = "481" style="position:absolute;top:0px;left:0px"></canvas>

This should now run on event the lowlest of devices that have a GPU and support the canvas. Remember that you must size the canvas to the screen via its width and height properties NOT via its style width and height properties.

\$\endgroup\$
  • \$\begingroup\$ wow, that's a pretty detailed explanation. Unfortunately, many animations of my project is already using paper.js, so it would take to long for me to change to canvas2D api or any other library. As for canvas size, i am actually using css to make the canvas the same size as the body, since certain animation will be running through the whole page. Is this a really bad practise? \$\endgroup\$ – Julian Tong Dec 1 '18 at 7:57
  • \$\begingroup\$ @JulianTong Yes it is bad. canvas.style.width and canvas.style.height set the display size not the resolution of the canvas, To set the canvas resolution you need to set the canvas.width and canvas.height properties. You will also need to scale the content. I am sure that paper has a way to scale the content to fit the new canvas size, as it is a standard function in the canvas 2D API \$\endgroup\$ – Blindman67 Dec 1 '18 at 8:26
2
\$\begingroup\$

This might be a small improvement but there is no need to check that destination is inside boundingRec on each frame, you just need to check it when creating a new destination.

Here is a modified codepen in which destination creation is done in a separated function, removing the unneeded check:

function createNewDestination(originalPosition, radius) {
    var destination = null;
    while (!destination || !destination.isInside(boundingRec)) {
        destination = originalPosition.add(paper.Point.random().multiply(radius));
    }
    return destination;
}
\$\endgroup\$
  • \$\begingroup\$ Thanks for the suggestion. Added the improvement that you've suggested, but i'm still getting pretty low frame rates (around 20fps) on mobile. I'm thinking of implementing offscreen canvas in web workers. Problem is, offscreen canvas is still a experimental technology. \$\endgroup\$ – Julian Tong Nov 30 '18 at 9:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.