2
\$\begingroup\$

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?

\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

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 when Particles is all that is needed

  • my_Particles_Manipulation Never use my (unless its instructional code) This name can just be particles, or smoke

  • 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 nameaddis all that is needed. If you were adding monkeys then maybeaddMonkeys`

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 and new.

  • 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;
}

\$\endgroup\$
5
  • \$\begingroup\$ Modern high-performance garbage collectors and object allocators are extremely efficient. However, they typically rely on the Generational Hypotheses to gain their performance: 1) Objects die young, 2) Old objects never reference new objects. Also, mutation destroys performance. Object pools screw with all three: they unnaturally extend the lifetimes of objects, they typically lead to older objects referencing newer objects at least some of the time, and they rely on mutation (resetting an object to a starting state instead of discarding the object and creating a new one). \$\endgroup\$ Commented Apr 5, 2021 at 15:23
  • \$\begingroup\$ This absolutely destroys performance. \$\endgroup\$ Commented Apr 5, 2021 at 15:23
  • \$\begingroup\$ Do you have a good reference for mutation destroys performance? \$\endgroup\$
    – konijn
    Commented Apr 5, 2021 at 17:12
  • \$\begingroup\$ @JörgWMittag Every point in your comment is wrong. All my Objects are frozen and can't be mutated, No particle dies and incur NO GC and only one instantiation hit.. I don't know what you mean by "Old objects never reference new objects" Objs are not references,. references refer to objs, you can bind references to objs, however this is read only. Note no thistokens in my answer. There is no Particle or Particles obj (only as abstractions) I use closure to hold state and interface (frozen), Particles is only 2 arrays and a canvas. It does not hold the interface. AND last point... \$\endgroup\$
    – Blindman67
    Commented Apr 5, 2021 at 20:13
  • \$\begingroup\$ If pools destroy performance, what makes using pools use less memory, and run faster??? \$\endgroup\$
    – Blindman67
    Commented Apr 5, 2021 at 20:14
1
\$\begingroup\$

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
  • 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 thealpha` 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 even particleCount
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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