Problem
I’m teaching myself Object Oriented Programming in JavaScript and I’m looking over this small P5 code of CodingTrain’s No.78, which deals with flying particles in the canvas, as a material.
The full code is below:
// Daniel Shiffman
// http://codingtra.in
// Simple Particle System
// https://youtu.be/UcdigVaIYAk
const particles = [];
function setup() {
createCanvas(600, 400);
}
function draw() {
background(0);
for (let i = 0; i < 5; i++) {
let p = new Particle();
particles.push(p);
}
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
particles[i].show();
if (particles[i].finished()) {
// remove this particle
particles.splice(i, 1);
}
}
}
class Particle {
constructor() {
this.x = 300;
this.y = 380;
this.vx = random(-1, 1);
this.vy = random(-5, -1);
this.alpha = 255;
}
finished() {
return this.alpha < 0;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.alpha -= 5;
}
show() {
noStroke();
//stroke(255);
fill(255, this.alpha);
ellipse(this.x, this.y, 16);
}
}
Wanting to train my OOP skill, I’m trying to refactor this code into more sophisticated one in the OOP point of view. So I refactored it by adding Particles_Manipulation
class and moving the process written in draw
function into the Particles_Manipulation
class as action
method.
The code is below:
// Daniel Shiffman
// http://codingtra.in
// Simple Particle System
// https://youtu.be/UcdigVaIYAk
class Particle {
constructor() {
this.x = 300;
this.y = 380;
this.vx = random(-1, 1);
this.vy = random(-5, -1);
this.alpha = 255;
}
finished() {
return this.alpha < 0;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.alpha -= 5;
}
show() {
noStroke();
//stroke(255);
fill(255, this.alpha);
ellipse(this.x, this.y, 16);
}
}
class Particles_Manipulation{
constructor(){
this.particles = [];
}
push_particles(_n){
for (let i = 0; i < _n; i++) {
let p = new Particle();
this.particles.push(p);
}
}
action(){
for (let i = this.particles.length - 1; i >= 0; i--) {
this.particles[i].update();
this.particles[i].show();
if (this.particles[i].finished()) {
// remove this particle
this.particles.splice(i, 1);
}
}
}
}
const my_Particles_Manipulation = new Particles_Manipulation();
function setup() {
createCanvas(600, 400);
}
function draw() {
background(0);
my_Particles_Manipulation.push_particles(5);
my_Particles_Manipulation.action();
}
Could you evaluate my refactoring is nice or not?
Solution
Magic numbers
Avoid magic numbers. Magic numbers are just numbers in your code that represent some abstract data value. The problem is that they are spreed within the code. To make changes to these values requires searching the code and often the same values are repeated making the task even more difficult.
Defining the magic numbers as named constants makes making changes very easy. See rewrite
Using native API’s
P5 is great but using it to create such a simple rendering task is not worth the need to load a huge chunk of code to do what the native API will do quicker (No need for the P5’s overhead)
Naming
There are a lot of problems with your naming style.
JS naming convention
The naming convention for JavaScript is to use lowerCamelCase for all names with the exception of objects created with the new token which uses UpperCamelCase (Static objects and object factories can also use UpperCamelCase). For constants you can use UPPER_SNAKE_CASE
_underscoredNames
Use underscore to avoid possible naming classes in systems where you have no control of names.
Using underscore should only ever be a very last option and should never be used if there is no need. Eg the argument _n
in push_particles(_n) {
should be pushParticles(n)
Avoid redundancy
It is easy to add too much to a name. Keep names short and use as few words as possible. Names are never created in isolation and like a natural language requires context to have meaning.
Eg
-
Particles_Manipulation
is overkill whenParticles
is all that is needed -
my_Particles_Manipulation
Never usemy
(unless its instructional code) This name can just beparticles
, orsmoke
-
push_particles
(don’t repeat names) What does this function do? “Adds particles”.push becomes
add. What does it add particles to? "The Particles object", thus
particlescan be inferred. The name
addis all that is needed. If you were adding monkeys then maybe
addMonkeys`
Avoid repeated long references
Avoid doing the same long reference path and array indexing by store the object reference in a variable.
this.particles[i].update();
this.particles[i].show();
if (this.particles[i].finished()) {
// remove this particle
this.particles.splice(i, 1);
}
Becomes
const p = this.particles[i];
p.update();
p.show();
if (p.finished()) {
this.particles.splice(i, 1);
}
Keep an eye on Logic
The render system reduces the particle alpha each step. You use the alpha value to check when to remove particles alpha < 0
. However you reduce the alpha, then render, then remove. That means than once going every frame you are trying to render 5 invisible particles. Remove befor rendering.
JS and Memory
Javascript is a managed language. Meaning that it manages memory for you.
This is great in most cases but it comes with a serious performance hit in applications like particle systems where many small independent objects are frequently being created and destroyed .
Object pool
To avoid the memory management overhead we use object pools.
An object pool is just an array of objects that are not being used. When an object is no longer needed we remove it from the main array and put it in the pool. When a new object is needed we check the pool, if there are any object in the pool we use it rather than create a new one.
This means that the particle object will need a function that resets it.
See rewrite
Note you can also pool the particles in the one array by using a bubble iterator to avoid the expensive @MDN.JS.OBJ.Array.slice@. Dead particle bubble to one end of the array. This is even faster but makes the inner update loop a lot more complex. You would only do this if you where using 10K+ particles.
JavaScript OO design
Many believe that using class
is how to write good OO JavaScript. Sorry to say it is not and is in fact the worst way to write OO JavaScript. The class syntax does not provide a good encapsulation model, its only plus is easy inheritance, which is kind of redundant in lieu of JavaScript’s lose polymorphism
In this case factory functions (a function that creates Object AKA Object factory) is best suited. Because we use an object pool when need not even bother with assigning a prototytpe
for particle functions.
Some advantages of factory functions
-
This eliminates the need to use
this
andnew
. -
Lets you define private properties without the need to
#hack
names -
Lets you create objects before the first execution pass has completed (move the init and setups to the top where it belongs)
Some disadvantages of factory functions
-
Requires a good understanding of closure and Scope both of which are a difficult subjects to get a good understanding of.
-
Does not work well with object prototypes.
Rewrite
The rewrite uses the CanvasRenderingContext2D API to avoid the overhead of P5, Some replacement functions are at the bottom.
Note that when defining constants it is always a good idea to include unit types, ranges, and or a description as a comment
animate();
;(() => {
const CANVAS_WIDTH = 600; // in CSS pixels
const CANVAS_HEIGHT = 400;
const PARTICLE_SPREAD = 1; // max x speed in canvas pixels
const PARTICLE_POWER = 3; // max y speed in canvas pixels
const PARTICLE_RADIUS = 12; // max y speed in canvas pixels
const MAX_PARTICLES = 1000; // Approx max particle count
const PARTICLES_RATE = 3; // new particle per frame
const SMOKE_PARTICLES_PATH = new Path2D; // circle used to render particle
const PARTICLE_COLOR = "#FFF"; // any valid CSS color value
const START_ALPHA = 1; // particle start alpha max
const START_ALPHA_MIN = 0.5; // particle start alpha
// Rate particle alpha decays to match MAX_PARTICLES. The life time in seconds
// can be calculates as Math.ceil(1 / ALPHA_DECAY_SPEED) * 60
const ALPHA_DECAY_SPEED = 1 / (MAX_PARTICLES / PARTICLES_RATE);
SMOKE_PARTICLES_PATH.arc(0, 0, PARTICLE_RADIUS, 0, Math.PI * 2);
const canvas = createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const smoke = new Particles(canvas);
animate.draw = function() {
canvas.ctx.setTransform(1, 0, 0, 1, 0, 0);
canvas.ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
smoke.add(PARTICLES_RATE);
smoke.update();
}
function Particle() {
var x, y, vx, vy, alpha;
const PARTICLE = {
init() {
x = CANVAS_WIDTH / 2;
y = CANVAS_HEIGHT;
vx = random(-PARTICLE_SPREAD, PARTICLE_SPREAD);
vy = random(-PARTICLE_POWER, -PARTICLE_POWER);
alpha = random(START_ALPHA_MIN, START_ALPHA);
return PARTICLE;
},
update() {
x += vx;
y += vy;
alpha -= ALPHA_DECAY_SPEED;
return alpha < 0;
},
draw(ctx) {
ctx.globalAlpha = alpha;
ctx.setTransform(1, 0, 0, 1, x, y);
ctx.fill(SMOKE_PARTICLES_PATH);
},
};
return Object.freeze(PARTICLE.init());
}
function Particles(canvas) {
const particles = [];
const pool = [];
const ctx = canvas.ctx;
return Object.freeze({
add(n) {
while (n-- > 0) {
pool.length ?
particles.push(pool.pop().init()) :
particles.push(Particle());
}
},
update() {
var i = 0;
ctx.fillStyle = PARTICLE_COLOR;
while (i < particles.length) {
const p = particles[i];
p.update() ?
pool.push(particles.splice(i--, 1)[0]) :
p.draw(ctx);
i++;
}
}
});
}
})();
// functions to replace P5
function createCanvas(width, height) {
const canvas = Object.assign(document.createElement("canvas"), {width, height, className: "renderCanvas"});
document.body.appendChild(canvas);
canvas.ctx = canvas.getContext("2d");
return canvas;
}
function random(min, max) { return Math.random() * (max - min) + min }
function animate() {
animate.draw?.();
requestAnimationFrame(animate);
}
.renderCanvas {
background: black;
}
From a short review;
- In OOP, your objects are supposed to be re-usable
- I would take the
x
,y
from a parameter - I would take the
vx
,vy
from a parameter - I would even take the
alpha
from a parameter
- I would take the
- Its nicer to call
finished
->isFinished
, this way the reader expects a boolean to return - In
update
, it makes sense to update the location with velocity, but I would have expected the particle to take a ‘updateFunctionwhich would reduce the
alpha` by 5 - In
show
, 16 should be either a nicely named constant, or something that can be set by the user of the particle. It breaks currently the rule of magic numbers, and it makes the class less re-usable Particles_Manipulation
->ParticlesManipulation
- No fan of underscores,
_n
->n
or evenparticleCount