Drawing Right Side
By Alex Rintt (Last update November 30, 2022)

November 30, 2022Edit this file (Private, ask me for access if you wanna contribute)

Creating a simple stargazing effect with p5.js

From a set of points, generate a stargazing effect.

image

Most of the times I was searching for cool animation effects, Google, StackOverflow, CodePen, GitHub; and I was missing the best part: it's creation. And to be true, my math skills are not that sharp, even now, so here we are: how to create a simple stargazing effect that looks great without any advanced geometry concept?

This is the effect we are going to build in this post:

https://youtu.be/T51_Z_GxdGY

You can also edit it's p5.js sketch at editor.p5/alexrintt/rising.

Even though it's pretty simple (created years ago) I wanna make a reboot of this one with more cooler effects, or even better: create animation to explain math concepts, like area, distance, collision, forces; but this is a later talk.

Steps Overview

First off lets take care of building some high-level steps:

  • Setup the canvas the starter variables.
  • Generate a star instance with a position that is randomly decided at the bottom or right side of the canvas.
  • Then lets update it's position according to it's velocity to add the motion effect.
  • Lets create a gradient animation to improve it's "space feeling".

Setting up the canvas

Lets create a blank canvas.

p5.js has two variables called windowWidth and windowHeight and it let us know all the available space on the screen.

// How many stars can be appearing at once, greather values means more dense effect since there will have more stars appearing at the screen.
// In our case we want the default stars count, feel free to force a hardcoded value and see how it behaves.
const STARS_COUNT = null

// Minimum and maximum size of a star (respectively).
// The size is rendered in pixels and represents the width and the height, yes, our stars are simply squares.
const STAR_SIZE = [5, 20]

// The array containing all the stars that are being rendered in the screen.
// The initial state is an empty array and is full-filled later on.
let stars = []

function setup() {
  // Tells p5.js to create a canvas with all available space.
  createCanvas(windowWidth, windowHeight)

  // We get our max size defined earlier to use in the next computation.
  const [_, maxSize] = STAR_SIZE.map(Math.abs)  

  // We get the greatest side of the screen and divide by the maximum size a star can be.
  // The result is a pretty comfortable density resolution, though tested only in landscape mode.
  const starsCount = STARS_COUNT || max(windowWidth, windowHeight) / maxSize

  // This is the same of a for loop and pushing each iteration a new star
  // for (let i = 0; i < starsCount; i++) stars[i] = createStar()
  stars = Array.from({ length: starsCount }).map(_ => createStar()) // createStar() is not implemented yet.
}

The main idea of this method is just to setup the initial state and our canvas, there's nothing special.

Our initial state is also pretty simple: we want to render a set of stars onto the screen. So lets create N stars and append to the stars state array.

In the next step we are going to implement the createStar method.

Generate an star instance

Now lets "create some stars":

// Velocity X and Y receive different values.
const BIDIRECTIONAL = `BIDIRECTIONAL`

// Velocity X and Y receive the same values.
const UNIDIRECTIONAL = `UNIDIRECTIONAL`

const STAR_GENERATION_MODE = UNIDIRECTIONAL

// Min and max velocity of an star, it's randomly decided for each star.
const STAR_VELOCITY_MIN_AND_MAX = [1.5, 20]

// How fast each star reduces it's sizes to zero, min and max values respectively, must be > 0.
const SIZE_DECREASE_FACTOR = [0.1, 0.4]

function createStar() {
  // Get the previous defined max and min star size with absolute (non-negative) values.
  const [minSize, maxSize] = STAR_SIZE.map(Math.abs)

  // Randomly decide the star size.
  const size = random(minSize, maxSize)

  // Here we generate a random value between [STAR_VELOCITY_MIN_AND_MAX].
  const velocityX = random(...STAR_VELOCITY_MIN_AND_MAX)
  const velocityY = random(...STAR_VELOCITY_MIN_AND_MAX)

  return {
    velocity: {
      x: velocityX / 3,
      y: STAR_GENERATION_MODE === BIDIRECTIONAL ? velocityY : velocityX,
    },
    size, // Size of the star in pixels and relative to the canvas itself.
    position: generateRandomInitialPosition(), // Not implemented yet.
    sizeDecreaseFactor: random(...SIZE_DECREASE_FACTOR), // Here we define a random value to each star.
    initialSize: size, // Stores the initial size, so we can apply lerp to the alpha value.
  }
}

The idea behind this method is to instantiate a star with a position, velocity and size.

We also add modifier variables that tell us how should we update our star each frame like sizeDecreaseFactor (how many size the star loses each frame) so we can add individual values to each star to give a more dynamic feeling instead a bunch of rects reducing it sizes equally; feel free to extend by yourself to add even more cooler effects like bezier curves, real 3d effects, etc.

But now lets dig into this method:

  • We defined a new constant called STAR_GENERATION_MODE that will define if our stars will have either a different velocity for it's X and Y positions (bidirectional) or the same value for both directions (unidirectional), at the end of this post there's a difference video between these two constants.
  • Another min/max based constant is STAR_VELOCITY_MIN_AND_MAX where we define the maximum and minimum velocity an star can randomly have.
  • Create our random factor by calling the p5.js random() method, it returns a random decimal number between 0 and 1 when calling without args as we did.
  • In order to generate a star of a random size we need to know the max and min size, so we get it from the STAR_SIZE array we defined when setting up the canvas.
  • Randomly decide the star size, by getting the interval delta (max - min), multiplying to the random factor and summing with the min value, it will always end up in a number more or equal than the minimum value and less than the maximum value.
  • Time to define the star speed. velocityX and velocityY are two values that are defined from the STAR_VELOCITY_MIN_AND_MAX constanst. We basically spread it's values onto the random() method to generate a random value between these bounds (min and max), then we multiply by -1 because our stars should flow right to left and bottom to top, which means the velocity should be reversed.

Paiting onto the canvas

Since we already have our canvas, the stars, lets try to paint it:

function draw() {
  // We do not want our stars have a border.
  noStroke()

  // Clear the previous frame canvas buffer.
  clear(0, 0, width, height)

  // Iterate over all the stars and draw it.
  for(const star of stars) {
    drawStar(star)
  }
}

const RED = `#F70000`
const ALMOST_GREEN = `#3DD2D0`
const MAIN_COLOR = ALMOST_GREEN // Try also [RED]

function drawStar(star) {
  // Calculate the remaining percentage of the star compared to it's initial size.
  // And applies it's equivalent alpha value: less are the size, less are the alpha channel (opacity).
  const alpha = star.size / star.initialSize * 255

  // Just convert the convenient hex string we defined earlier to
  // an array of three elements (red, green, blue; rgb) respectively.
  const rgb = hex2rgb(MAIN_COLOR)

  // Use it as fill color.
  fill(...rgb, alpha)

  // Then draw our rect by using it's position and size.
  rect(star.position.x, star.position.y, star.size, star.size)
}

// Helper function to convert a given hexadecimal (7-length) string "#ffffff"
// to a an array of three elements representing it's red, green and blue (rgb) components.
// https://stackoverflow.com/a/69353003/11793117
function hex2rgb(hex) {
  const r = parseInt(hex.slice(1, 3), 16)
  const g = parseInt(hex.slice(3, 5), 16)
  const b = parseInt(hex.slice(5, 7), 16)
  return [r, g, b]
}

The draw function is called 60 times per second, this is popular in animation and game development and it's called game loop. You can force a framerate by calling frameRate(144) in the setup method.

Notice the clear(...) method call; since an animation is sequence of frames, if an animation keep the previous frames then it will buggy and not flow as expected because the buffer of the previous paint is still visible, this is a default behavior that we do not want here, remove this function call if you are curious to what happens.

Another simple detail is that we are using a type of lerp to calculate the alpha value.

The expression is:

One of linear interpolation functions:

lerp(lower, upper, value) = lower + (upper - lower) * value

Application on the alpha variable:

alpha = (current_size / initial_size) * 255

Where implicitly we are setting the lower bound to zero and the upper bound to 255, and the t value to the percentage (in decimal) of the remaining size.

But before running, lets implement our missing method generateRandomInitialPosition:

// Generates randomly an initial position on the right/bottom sides.
function generateRandomInitialPosition() {
  // Randomly decides if we are going to spawn 
  // the star on the right or bottom side of the screen.
  const randomBottomPosition = random() <= 0.9

  // [width] and [height] variables actually come from createCanvas(arg1, arg2) respectively.
  return {
    x: randomBottomPosition ? random() * width : width,
    y: randomBottomPosition ? height : random() * height
  }
}

Now you can run and you will see… a blank screen!

That's because we are rendering our stars at it's initial position, and it is off-screen!

So in the next step we need to update the stars throughout the animation loop cycle.

Update the star position and size

In order to add effect (and to make the stars visible) lets write our function to update the star position according to it's velocity + update the star size according to it's size decreasing factor:

// Receives an star object and returns another object with it's updated properties.
function updateStar({ position, velocity, size, sizeDecreaseFactor, initialSize }) {
  return {
    position: {
      // Remember: we're going backwards (bottom to top and right to left) so we need to subtract.
      x: position.x - velocity.x, // Here we subtract the current X position with it's velocity X.
      y: position.y - velocity.y // Same for velocity Y.
    },
    size: size - sizeDecreaseFactor, // Take the size - the decreasing factor.
    initialSize, // Keeps the initial size.
    sizeDecreaseFactor, // Keep the factor.
    velocity // Keep the velocity, feel free to implement an acceleration.
  }
}

Here we did it. Lets now update our draw method to actually make use of this function:

function draw() {
  // We do not want our stars have a border.
  noStroke()

  // Clear the previous frame canvas buffer.
  clear(0, 0, width, height)

  // Iterate over all the stars and draw it.
  for(const star of stars) {
    drawStar(star)

    // Update the star and replaces the original one
    const updatedStar = updateStar(star)
    stars[stars.indexOf(star)] = updatedStar
  }
}

We can now run our p5.js snippet and see the following result:

nocollisioneffect

Well, we are seeing something at least…

Now lets understand what is not working: new stars are not spawing! and we don't know when the stars are off-screen (left the scene).

So lets create a function to detect this "collision", that is, verify if a given star is inside the visible screen:

function starIsVisible(star) {
  return star.position.x + star.size < width && 
          star.position.x + star.size > 0 &&
          star.position.y + star.size < height &&
          star.position.y + star.size > 0 &&
          star.size > 0
}

This is the space we are working with:

canvascoord

So, notice the following requirements:

  • X must be greather than zero and less than width.
  • Y must be greather than zero and less than height.
  • The size of star also must be greather than zero, otherwise it's no longer visible.
  • The size must be included in the coordinate computation, otherwise we will be checking the collision only of the origin point (the top left of the rect) instead of the rect itself (bottom right).

So we can use the function like this:

if (starIsVisible(star)) print('Star is visible')
else print('Star is off-screen!')

So lets implement it on our draw function:

function draw() {
  // We do not want our stars have a border.
  noStroke()

  background(0)
  // Clear the previous frame canvas buffer.
  clear(0, 0, width, height)

  // Iterate over all the stars and draw it.
  for(const star of stars) {
    drawStar(star)

    // Create the updated star object.
    const updatedStar = updateStar(star)    

    if (starIsVisible(updatedStar)) {
      // If it's visible, update the current star.
      stars[stars.indexOf(star)] = updatedStar
    } else {
      // Otherwise, if the star is no longer visible...
      // Generate a new one!
      stars[stars.indexOf(star)] = createStar()
    }
  }
}

Done, our effect is alive… next step we will generate our gradient effect + the dark background.

The gradient effect

Now, lets copy the gradient function that p5.js does have in it's documentation:

// Draw a gradient given a point and it's width and height; initial color and final color.
function setGradient(x, y, w, h, c1, c2) {
  noFill();

  // Top to bottom gradient
  for (let i = y; i <= y + h; i++) {
    let inter = map(i, y, y + h, 0, 1);
    let c = lerpColor(c1, c2, inter);
    stroke(c);
    line(x, i, x + w, i);
  }
}

I removed the axis argument because we are not going to use the X_AXIS, only the Y_AXIS.

Now, lets use it inside of our draw function:

const _50ms = 50
const _100ms = 50 * 2
const _1s = _100ms * 10
const _5s = _1s * 5
const _10s = _5s * 2

// Constant to define the gradient animation duration.
const GRADIENT_ANIMATION_DURATION = _5s

function draw() {
  noStroke()
  clear(0, 0, width, height)

  // Call the set linear gradient function.
  setLinearGradientBackground()

  for(const star of stars) {
    drawStar(star)

    const updatedStar = updateStar(star)

    if (starIsVisible(updatedStar)) {
      stars[stars.indexOf(star)] = updatedStar
    } else {
      stars[stars.indexOf(star)] = createStar()
    }
  }
}

function setLinearGradientBackground() {
  // Define the background color as an almost black color.
  background(0, 0, 20)

  // A value of the animation current state.
  // It will get the division modulus of the [GRADIENT_ANIMATION_DURATION].
  // And divide by the [GRADIENT_ANIMATION_DURATION] itself.
  // This computate a decimal number between 0 and 1 every [GRADIENT_ANIMATION_DURATION] seconds.
  const value = (millis() % GRADIENT_ANIMATION_DURATION) / GRADIENT_ANIMATION_DURATION

  // We want the effect of starting and then fading out.
  const curve = value > 0.5 ? 1 - value : value

  // Multiply by 255 to get the alpha value.
  const gradientAlpha = curve * 255

  // Create the color based on the constant [MAIN_COLOR].
  const gradientColor = color(...hex2rgb(MAIN_COLOR), gradientAlpha)

  // Then set the gradient through our function created earlier.
  setGradient(0, 0, width, height, color(0, 0, 0, 0), gradientColor)
}

It's done, see the final result in the next step.

Final result

First, let me show how the effect looks like when the stars receive the same constants rather of randomly ones individually:

prettyboringhuh

Pretty boring, right?

The final result (for both uni and bi directional):

https://youtu.be/T51_Z_GxdGY

See also

Files in the same folder:

  • Donation - I accept donations from several platforms, this post serve as permalink to my documentation/credit pages for each project. These links allow supporters to easily find and donate to the projects they are interested in, while ensuring that all donations are properly credited and acknowledged.
  • LeetCode, Two Sum problem solution using brute force in Dart - Two Sum LeetCode problem solution written in Dart in complexity O(N).
  • Acho que esse é um dos maiores crimes que já cometi - Criei um blog estático em NodeJS com puro HTML e CSS misturado com React e JavaScript + Babel, API, database, regra de negócio tudo em 1 único arquivo com 700 linhas.
  • Qual a maneira mais simples de evoluir a si mesmo? - Se você já se perguntou, 'Como posso ser melhor?' em qualquer aspecto. Essa dica é definitivamente para você!
  • Mr. Fear - Medo de aprender, medo de perguntar, medo de errar, medo de falar em público, medo de altura, medo de cruzar aquele beco num sábado a noite no centro da zona leste. Essas 4 letras não tem espaço suficiente pra agrupar essa complexa quantidade de sentimentos.
  • Listar os últimos usuários que deram star em um repositório do GitHub - Com a API rest é impossível listar em order decrescente, portanto utilizaremos a GraphQL API do GitHub.
  • Botando React Redux de joelho no milho - Você ai já teve aquela fase em que ouvia a palavra 'redux' e sentava em posição fetal chorando? Então esse post é para você. Bora simplificar o estado global e implementar um gg 10/10 izi

Anonymous message

The form above is anonymous, although the following data is collected:

  • Current page URL.
  • Your message.
  • Your username if provided.
  • Don't forget to include contact info if you are asking something, otherwise I'll never be able to reach you back!

If you prefer, you can reach me directly on GitHub or E-mail.

This form is powered by Discord Webhook API, so it is totally hackable, enjoy.