p5.js

Trying to Create a P5.js Screensaver


I'm new to P5.js and JavaScript programming in general, but I've taken a few lessons and am trying to make a few interesting images with what I know. However, I reached a few roadblocks while trying to program a constantly moving screensaver.

The project I'm trying to make is an expanding circle within a set of coordinates in an array that spread outwards, repeating continuously at a different coordinate after it reaches the edge of the canvas. This is supposed to mimic the waves that raindrops make on water.

The problems I'm facing are that I don't know how to make the circles appear one by one and at random, rather than all at once. Additionally, I have a function that changes the color each circle is from the hex values within another array, but for some reason it only remains the singular color rather than changing.

Any advice at all would help, but I would preferably be able to use the specific points on the location arrays.

let size = ["25"];
//The size each circle starts at
let colorShift;
//Empty value to make an array
let locationsX = ["50", "50", "100", "150", "100", "150", "200", "250", "300", "350", "300", "250", "350", "50", "100", "300", "350", "200", "200", "200", "200"];
let locationsY = ["50", "350", "300", "250", "100", "150", "200", "250", "300", "50", "100", "150", "350", "200", "200", "200", "200", "50", "100", "300", "350"];
//The points in which I want circles to appear

function setup() {
  createCanvas(400, 400);

  background("#004CC2");
  
  colorShift = [color("#00C2BF"), color("#33FFFC"), color("#33A2FF"), color("#3380FF")];
  //The colors within the array
}

function draw() {
  stroke("white");
  strokeWeight(1);
  colorChanging();
  //Calls the color changing function

  for (i = 0; i < locationsX.length; i++) {
    circle(locationsX[i], locationsY[i], size[0]);
  }
  //Issue, creates all circles within the array coordinates

  //Goal, create an individual circle randomly from one of the coordinates in the array, continuously


  for (i = 0; i < size.length; i++) {
    size[i]++;
    if (size[i] > 100) {             //Test Size
      //if (size[i] > 600) {         //Final Size
      size[i] = 0;
    }
  }
  //Expands the circles until the specified size, wherein they reset
}

function colorChanging() {
  for (i = 0; i < colorShift.length; i++) {
    fill(colorShift[i]);
    //colorShift[i]++;

    //Issue, does not shift through the color array, only one color or blank white when using colorShift[i]++

    if (colorShift[i] > 3) {
      colorShift[i] = 0;
      //Will reset the color if it exceeds the colors in the array
    }
    //Issue, does not shift through the color array, only one color or blank white
  }
  //Goal, shift the inside of each circle between the colors in array
}

Solution

  • Reaching a few roadblocks is a good reminder to pause, break the problem down into smaller, simpler problems then tackle one at a time.

    Your question mainly is about how to update circle properties (size and color) independently to create a raindrop like effect.

    The animation looks uniform because you are updating all the circles at the same time as well as the circles starting with the same size (25). Simply randomising the initial size of the circles can help the illusion.

    There are quite a few opportunities to improve your code. The intention is not to discourage, on the contrary, to hopefully help build better habits early on. You already do a few good things:

    Now on the improvements: Try not to mix data types (to avoid unwanted behaviour). JS is not strictly typed. size, locationsX and locationsY are initialised with string values (e.g. size contains "25"), when it should be Number values (e.g. size = [25] (no quotes)).

    If you want to transition from one color to the next you can use lerpColor(). It takes 3 arguments:

    (You could map the circle size, for example, in this normalised range given that you know the current and maximum size of the circle (e.g. size / maxSize will be a ratio from 0.0 to 1.0). in other scenarios where the number ranges arent as straight forward to remap you can use the map() function)

    There a few drawing function calls that could move between setup() and draw()

    Additionally you're re-using the same circle size for all circles drawn (even if you're updating sizes indepdently (and the size array was initialised with one value). This may be a left-over from incrementally changing the sketch from one circles to many circles. (i.e. circle(locationsX[i], locationsY[i], size[0]); should be circle(locationsX[i], locationsY[i], size[i]);)

    With the smallest number of changes your code could render circles appearing to grow at different rates like so:

    let size = [];
    //The size each circle starts at
    let colorShift;
    //Empty value to make an array
    let locationsX = ["50", "50", "100", "150", "100", "150", "200", "250", "300", "350", "300", "250", "350", "50", "100", "300", "350", "200", "200", "200", "200"];
    let locationsY = ["50", "350", "300", "250", "100", "150", "200", "250", "300", "50", "100", "150", "350", "200", "200", "200", "200", "50", "100", "300", "350"];
    //The points in which I want circles to appear
    
    function setup() {
      createCanvas(400, 400);
    
      
      
      colorShift = [color("#00C2BF"), color("#33FFFC"), color("#33A2FF"), color("#3380FF")];
      //The colors within the array
      for (let i = 0; i < locationsX.length; i++) {
        size[i] = random(100);
      }
    }
    
    function draw() {
      background("#004CC2");
      stroke("white");
      strokeWeight(1);
      colorChanging();
      //Calls the color changing function
    
      for (let i = 0; i < locationsX.length; i++) {
        circle(locationsX[i], locationsY[i], size[i]);
      }
      //Issue, creates all circles within the array coordinates
    
      //Goal, create an individual circle randomly from one of the coordinates in the array, continuously
    
    
      for (let i = 0; i < size.length; i++) {
        size[i]++;
        if (size[i] > 100) {             //Test Size
          //if (size[i] > 600) {         //Final Size
          size[i] = 0;
        }
      }
      //Expands the circles until the specified size, wherein they reset
    }
    
    function colorChanging() {
      for (let i = 0; i < colorShift.length; i++) {
        fill(colorShift[i]);
        //colorShift[i]++;
    
        //Issue, does not shift through the color array, only one color or blank white when using colorShift[i]++
    
        if (colorShift[i] > 3) {
          colorShift[i] = 0;
          //Will reset the color if it exceeds the colors in the array
        }
        //Issue, does not shift through the color array, only one color or blank white
      }
      //Goal, shift the inside of each circle between the colors in array
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.1/p5.min.js"></script>

    (key takeaways being:

    Here is a refactored version of your sketch with the above notes integrated:

    //Empty value to make an array
    let locationsX = [50, 50, 100, 150, 100, 150, 200, 250, 300, 350, 300, 250, 350, 50, 100, 300, 350, 200, 200, 200, 200];
    let locationsY = [50, 350, 300, 250, 100, 150, 200, 250, 300, 50, 100, 150, 350, 200, 200, 200, 200, 50, 100, 300, 350];
    //The points in which I want circles to appear
    let numCircles  = locationsX.length;
    let circleSizes = new Array(numCircles);
    //The size each circle starts at
    let colorPalette;
    // total number of colours in the palette
    let numColors;
    //Which color from the list is the current colour (transitioning from, to the next) 
    let circleColorIndices = new Array(numCircles).fill(0);
    // color transition duration in frames
    let circleMaxSize = 100;
    // store each circle's color
    let circleColors;
    
    
    function setup() {
      createCanvas(400, 400);
      stroke("white");
      strokeWeight(1);
      
      // init palette
      colorPalette = [color("#00C2BF"), color("#33FFFC"), color("#33A2FF"), color("#3380FF")];
      numColors = colorPalette.length;
      // init individual circle colors
      circleColors = new Array(numCircles).fill(colorPalette[0]);
      // randomize circle sizes - fake appearance raindrop / growing at different times
      for (let i = 0; i < numCircles; i++) {
        circleSizes[i] = random(100);
      }
    }
    
    function draw() {
      background("#004CC2");
      
      for (let i = 0; i < numCircles; i++) {
        growCircle(i);
        changeCircleColor(i);
        // isolate fill properties
        push()
          fill(circleColors[i]);
          circle(locationsX[i], locationsY[i], circleSizes[i]);
        pop();
      }
    }
    
    function growCircle(index){
      circleSizes[index]++;
      if (circleSizes[index] > circleMaxSize) {             //Test Size
        circleSizes[index] = 0;
        // increment color
        circleColorIndices[index] = (circleColorIndices[index] + 1) % numColors;
      }
    }
    
    function changeCircleColor(index){
      let currentColorIndex = circleColorIndices[index];
      let nextColorIndex    = (currentColorIndex + 1) % numColors;
      let colorLerpAmount   = circleSizes[index] / circleMaxSize;
      
      let circleColor       = lerpColor(colorPalette[currentColorIndex],
                                        colorPalette[nextColorIndex],
                                        colorLerpAmount);
      circleColors[index] = circleColor;  
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.1/p5.min.js"></script>

    The modulo/remainder operator (%) in this case is used to "loop" the next index back to the start of the array. for example:

    circleColorIndices[index]++;
    if(circleColorIndices[index] >= numColors){
      circleColorIndices[index] = 0;
    }
    

    would behave roughly the same as:

    circleColorIndices[index] = (circleColorIndices[index] + 1) % numColors;
    

    One last minor detail is introducing push()/pop(). One thing it can help with is isolate drawing styles (e.g. stroke, fill properties). This isn't 100% needed in this case since fill() is called before rendering each circle, however you may reach scenarios in the future where you want objects to be drawn in a certain style without affecting the global drawing style. It can also be used to isolate coordinate systems, not just drawing styles and this is more powerful. The 2D Transformations tutorial is great. Even though it uses the Processing Java syntax you can easily recognize how it applies to p5.js. Here's a minimal example rendering an octogon:

    function setup() {
      createCanvas(400, 400);
      background("#004CC2");
      fill("#00C2BF");
      
      // move to the center
      translate(width / 2, height / 2);
      
      let numSides = 8;
      let radius = 100;
      
      for(let i = 0 ; i <= numSides; i++){
        
        push();
          // rotate
          rotate(map(i, 0, numSides, 0.0, TWO_PI));
          // move to the right after rotation is applied
          translate(radius, 0);
          // draw
          circle(0, 0, 25);
        pop();
        
      }
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.1/p5.min.js"></script>

    (Notice how simple it is now to change the number of sides or radius)

    You might not be familiar with classes yet, however it may help see an example how you could group each circle data (size, color, position) and behaviour (updating properties, rendering) using instances of a class instead of multiple arrays:

    //Empty value to make an array
    let locationsX = [50, 50, 100, 150, 100, 150, 200, 250, 300, 350, 300, 250, 350, 50, 100, 300, 350, 200, 200, 200, 200];
    let locationsY = [50, 350, 300, 250, 100, 150, 200, 250, 300, 50, 100, 150, 350, 200, 200, 200, 200, 50, 100, 300, 350];
    //The points in which I want circles to appear
    let numCircles  = locationsX.length;
    let colorPalette;
    let numColors;
    let circles = [];
    let circleMaxSize = 100;
    
    function setup() {
      createCanvas(400, 400);
    
      colorPalette = [color("#00C2BF"), color("#33FFFC"), color("#33A2FF"), color("#3380FF")];
      numColors = colorPalette.length;
      
      for(let i = 0 ; i < numCircles; i++){
        circles[i] = new Circle(locationsX[i], locationsY[i], random(100));
      }
    }
    
    function draw() {
      background("#004CC2");
      
      stroke("white");
      strokeWeight(1);
      
      for (let i = 0; i < numCircles; i++) {
        circles[i].draw();
      }
    }
    
    class Circle{
      
      constructor(x, y, size){
        this.x = x;
        this.y = y;
        this.size = size;
        this.colorIndex = 0;
        this.color = colorPalette[this.colorIndex];
      }
      
      updateSize(){
        this.size++;
        if(this.size > circleMaxSize){
          this.size = 0;
          // increment color from palette
          this.colorIndex = (this.colorIndex + 1) % numColors;
        }
      }
      
      updateColor(){
        
        let nextColorIndex    = (this.colorIndex + 1) % numColors;
        let colorLerpAmount   = this.size / circleMaxSize;
    
        let circleColor       = lerpColor(colorPalette[this.colorIndex],
                                          colorPalette[nextColorIndex],
                                          colorLerpAmount);
        this.color = circleColor;
      }
      
      draw(){
        // update
        this.updateSize();
        this.updateColor();
        // render
        push();
        fill(this.color);
        circle(this.x, this.y, this.size);
        pop();
      }
      
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.1/p5.min.js"></script>

    Update: Thank you @ggorlen for pointing out that unless i is explicitly declared using the let keyword it implcitly becomes a global variable which is not desirable. (polutes global scope and came become a source of hidden bugs).

    @AidenA. FWIW here are a few of tips to potentially improve the visuals in the future:

    1. you can play with different size increments to add another dimension to the random looking circles
    2. you can play with blendMode() to explore different transparencies
    3. you can play with filter(BLUR) to smooth out edges

    Here's a sketch to illustrate this:

    //Empty value to make an array
    let locationsX = [50, 50, 100, 150, 100, 150, 200, 250, 300, 350, 300, 250, 350, 50, 100, 300, 350, 200, 200, 200, 200];
    let locationsY = [50, 350, 300, 250, 100, 150, 200, 250, 300, 50, 100, 150, 350, 200, 200, 200, 200, 50, 100, 300, 350];
    //The points in which I want circles to appear
    let numCircles  = locationsX.length;
    let colorPalette;
    let numColors;
    let circles = [];
    let circleMaxSize = 100;
    
    function setup() {
      createCanvas(400, 400);
      
      colorPalette = [color("#00C2BF"), color("#33FFFC"), color("#33A2FF"), color("#3380FF")];
      numColors = colorPalette.length;
      
      for(let i = 0 ; i < numCircles; i++){
        circles[i] = new Circle(locationsX[i], locationsY[i], random(100));
      }
    }
    
    function draw() {
      background("#004CC2");
      
      blendMode(OVERLAY);
      for (let i = 0; i < numCircles; i++) {
        circles[i].draw();
      }
      blendMode(BLEND);
      filter(BLUR, 8);
    }
    
    class Circle{
      
      constructor(x, y, size){
        this.x = x;
        this.y = y;
        this.size = size;
        this.colorIndex = 0;
        this.color = colorPalette[this.colorIndex];
        this.sizeSpeed = random(1, 3);
      }
      
      updateSize(){
        this.size += this.sizeSpeed;
        if(this.size > circleMaxSize){
          this.size = 0;
          // increment color from palette
          this.colorIndex = (this.colorIndex + 1) % numColors;
        }
      }
      
      updateColor(){
        
        let nextColorIndex    = (this.colorIndex + 1) % numColors;
        let colorLerpAmount   = this.size / circleMaxSize;
    
        let circleColor       = lerpColor(colorPalette[this.colorIndex],
                                          colorPalette[nextColorIndex],
                                          colorLerpAmount);
        this.color = circleColor;
      }
      
      draw(){
        // update
        this.updateSize();
        this.updateColor();
        // render
        push();
        noFill();
        stroke(this.color);
        strokeWeight(this.size * 0.1);
        circle(this.x, this.y, this.size);
        pop();
      }
      
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.1/p5.min.js"></script>

    Additionally, you'll notice random() might not be random enough / the pattern may become predictable after a while. You can look at noise() functions (e.g. also noiseSeed(), noiseDetail()) and randomGaussian(). When the color is reset (e.g. this.size = 0; in updateSize() you can also potentially "wobble" / randomise the positions a bit). HTH