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

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
Happy New Year 2024