Procedural Cloud Graphics Optimization
Optimizing for Performance
When I released greggoad.net, I had an animated background of a sky with clouds floating across the screen. The clouds were procedurally generated, where a cloud was generated, and then a series of nodes were generated along with the cloud. I got this concept from a blog post on the internet.
When I ran tests, I noticed I noticed that sometimes the browser would become bogged down, and scrolling would become jank: especially in the browser that FaceBook launches when you follow a link on mobile. This is unacceptable. Let's review the code, and see what we did to fix this.
Version 1
// Javascript Code
function Cloud(first){
var size=Math.sqrt((mainCanv.width/2)**2+(mainCanv.height/2)**2)*1.3;
this.active=true;
this.speed=0.1+Math.random();
var randTh=Math.random()*(rendE-rendS)+rendS;
this.x=hw-Math.cos(randTh)*cRad*1.5;
this.y=hh-Math.sin(randTh)*cRad*1.5;
if(first){
this.x=Math.random()*mainCanv.width;
this.y=Math.random()*mainCanv.height;
}
this.cores=[];
var mx=Math.floor(Math.random()*5)+1;
for(var i=0; i<mx; i++)
{
this.cores.push(new Node(
this, /* par */
10, /* max cores*/
50,/* maxdist*/
30,/* max rad,*/
0 /* level*/
));
}
};
Cloud.prototype.COLLECT=function(f,x,y){
x*=this.speed; y*=this.speed;
this.x+=x*f; this.y+=y*f;
if(
(x >= 0 && this.x > mainCanv.width) ||
(x < 0 && this.x < 0) ||
(y >= 0 && this.y > mainCanv.height) ||
(y < 0 && this.y < 0)
){
this.active=false;
}
this.cores=this.cores.filter(function(c){return c.active || c.cores.length;});
this.cores.forEach(function(c){c.COLLECT(f,x,y);});
};
function Node(par,maxCores, maxDist,maxRad, level,sx,sy){
this.level=level;
this.par=par;
var ang=Math.random()*tau;
var dist=Math.random()*maxDist;
this.rad=Math.random()*maxRad;
this.active=true;
if(typeof sx === 'undefined'){
this.x=par.x+Math.cos(ang)*dist;
this.y=par.y+Math.sin(ang)*dist;
}else{this.x=sx; this.y=sy; }
this.cores=[];
var mx=Math.floor(Math.random()*maxCores)+1
for(var i=0; i<mx && level < levels.length-1; i++)
{
// THIS IS THE CONFIGURATION
this.cores.push(new Node(
this,
maxCores*2,
maxDist-10,
maxRad-5,
level+1
));
}
};
Node.prototype.COLLECT=function(f,x,y){
levels[this.level].push(this);
this.x+=x*f; this.y+=y*f;
if(
(x >= 0 && this.x-this.rad > mainCanv.width) ||
(x < 0 && this.x+this.rad < 0) ||
(y >= 0 && this.y-this.rad > mainCanv.height) ||
(y < 0 && this.y+this.rad < 0)
){
this.active=false;
}
this.cores=this.cores.filter(function(c){return c.active || c.cores.length;});
this.cores.forEach(function(c){c.COLLECT(f,x,y);});
};
Diagnosis
Look at the worst case senario for the rendering of the clouds. We start with a cloud that has a max-node limit of 10. Each time a node is created, the max-nodes variable is multiplied by 2. We have 3 levels of nodes. So each cloud, in the worst case, would have 10 + 10x20 + (10x20)x40 = 8210 nodes per cloud! With 12 clouds, that's 98520 nodes in the worst case. Every node renders a circle. No wonder there was a performance problem. I landed on this configuration just by playing with the numbers until it produced clouds I liked.
Solition Part A
A Hard Limit
The first part of the solution was to add a hard limit on the number of nodes. I did some testing, using console logs, and landed on a number of around 500 nodes as the limit to maintain perfomance. I kept track of the nodes with a closure-variable, incrementing in the constructor, and decrementing in the destructor:
// JavaScript Code
var numCores=0;
var coreLimit=500;
// Then inside the cloud constructor:
function Cloud(first){
//// ... Omitted code for demonstation
this.cores=[];
var mx=Math.floor(Math.random()*5)+1;
for(var i=0; i<mx && numCores < coreLimit; i++)
{
this.cores.push(new Node(
this, /* par */
10, /* max cores*/
50,/* maxdist*/
30,/* max rad,*/
0 /* level*/
));
}
};
// Then inside the node constructor:
function Node(par,maxCores, maxDist,maxRad, level,sx,sy){
numCores++;
//// ... Omitted code for demonstation
for(var i=0; i<mx && level < levels.length-1 && numCores < coreLimit; i++)
{
this.cores.push(new Node(
this,
maxCores*2,
maxDist+5,
maxRad+10,
level+1
));
}
};
// Then inside the fuction where the node is flagged as inactive.
Node.prototype.COLLECT=function(f,x,y){
levels[this.level].push(this);
this.x+=x*f; this.y+=y*f;
if(
(x >= 0 && this.x-this.rad > mainCanv.width) ||
(x < 0 && this.x+this.rad < 0) ||
(y >= 0 && this.y-this.rad > mainCanv.height) ||
(y < 0 && this.y+this.rad < 0)
){
// Decrement the cores
if(this.active){numCores--;}
this.active=false;
}
this.cores=this.cores.filter(function(c){return c.active || c.cores.length;});
this.cores.forEach(function(c){c.COLLECT(f,x,y);});
};
Solition Part B
Configuration
This was all well and good, but without changing the configuration, then I would have 1, maybe 2 clouds max. So, I changed the numbers around. I reduced the number of nodes with a division, and made the child nodes grow in size, to envelope the previous nodes.
// JavaScript Code
function Node(par,maxCores, maxDist,maxRad, level,sx,sy){
numCores++;
this.level=level;
this.par=par;
var ang=Math.random()*tau;
var dist=Math.random()*maxDist;
this.rad=Math.random()*maxRad;
this.active=true;
if(typeof sx === 'undefined'){
this.x=par.x+Math.cos(ang)*dist;
this.y=par.y+Math.sin(ang)*dist;
}else{this.x=sx; this.y=sy; }
this.cores=[];
var mx=Math.floor(Math.random()*maxCores)+1
for(var i=0; i<mx && level < levels.length-1 && numCores < coreLimit; i++)
{
/// I Chose These Values
this.cores.push(new Node(
this,
maxCores/2,
maxDist+2,
maxRad+8,
level+1
));
}
};
The Final Code
// JavaScript Code
// Inside of the closure of a function.
var numCores=0;
var coreLimit=500;
function Cloud(first){
var size=Math.sqrt((mainCanv.width/2)**2+(mainCanv.height/2)**2)*1.3;
this.active=true;
this.speed=0.1+Math.random();
var randTh=Math.random()*(rendE-rendS)+rendS;
this.x=hw-Math.cos(randTh)*cRad*1.5;
this.y=hh-Math.sin(randTh)*cRad*1.5;
if(first){
this.x=Math.random()*mainCanv.width;
this.y=Math.random()*mainCanv.height;
}
this.cores=[];
var mx=Math.floor(Math.random()*5)+1;
for(var i=0; i<mx && numCores < coreLimit; i++)
{
this.cores.push(new Node(
this, /* par */
10, /* max cores*/
50,/* maxdist*/
30,/* max rad,*/
0 /* level*/
));
}
};
Cloud.prototype.COLLECT=function(f,x,y){
x*=this.speed; y*=this.speed;
this.x+=x*f; this.y+=y*f;
if(
(x >= 0 && this.x > mainCanv.width) ||
(x < 0 && this.x < 0) ||
(y >= 0 && this.y > mainCanv.height) ||
(y < 0 && this.y < 0)
){
this.active=false;
}
this.cores=this.cores.filter(function(c){return c.active || c.cores.length;});
this.cores.forEach(function(c){c.COLLECT(f,x,y);});
};
function Node(par,maxCores, maxDist,maxRad, level,sx,sy){
numCores++;
this.level=level;
this.par=par;
var ang=Math.random()*tau;
var dist=Math.random()*maxDist;
this.rad=Math.random()*maxRad;
this.active=true;
if(typeof sx === 'undefined'){
this.x=par.x+Math.cos(ang)*dist;
this.y=par.y+Math.sin(ang)*dist;
}else{this.x=sx; this.y=sy; }
this.cores=[];
var mx=Math.floor(Math.random()*maxCores)+1
for(var i=0; i<mx && level < levels.length-1 && numCores < coreLimit; i++)
{
this.cores.push(new Node(
this,
maxCores/2,
maxDist+2,
maxRad+8,
level+1
));
}
};
Node.prototype.COLLECT=function(f,x,y){
levels[this.level].push(this);
this.x+=x*f; this.y+=y*f;
if(
(x >= 0 && this.x-this.rad > mainCanv.width) ||
(x < 0 && this.x+this.rad < 0) ||
(y >= 0 && this.y-this.rad > mainCanv.height) ||
(y < 0 && this.y+this.rad < 0)
){
if(this.active){numCores--;}
this.active=false;
}
this.cores=this.cores.filter(function(c){return c.active || c.cores.length;});
this.cores.forEach(function(c){c.COLLECT(f,x,y);});
};
Comments for the Future
There are a few things that I see could be better. Sometimes, a orphaned circle gets created; I would like to limit that. But as it stands, I'm calling this good enough for now, and moving on. If I went further, I would really rather get away from the circle clusters and move toward some kind of bezier curve outline for the clouds.