Procedural Cloud Graphics Optimization

A moon barely visible through hazy clouds with a deep blue background
Through the Haze

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.

A red target with red lighting coming out of it that turns negative when it crosses the lines in the target
Targeting the problem.

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
	  ));
   }
};
A green checkmark in the style of a pottery sculpture
One more thing knocked off of the to-do list.

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.

Related Articles

I got a Job!
Valentine Love
The Launch of GG Hire Me
Link Indexer Complete
I Made a Git Hub!
Media Indexer Complete
2023 : A Web App Retrospective
Media Indexer Update