Fireworks

In this post, we will learn how to put together a spectacular animation like Anthony Galea's Fireworks. I have enhanced the source a bit in my implementation. It helped me unravel the role "blend mode" and g - acceleration due to gravity played in the animation. I have also simplifed the physics required to render the bombs and particles.



Like in my previous posts, I have used the excellent ClojureScript animation library Quil to implement this. Quil itself is a Clojure implementation over Processing-js library. Quil has an elegant fun mode to code animations. The fact that we may express fireworks like above as pure functions, paves the way for greater experimentation and understanding.


My standard disclaimer. Don't be thrown off if you are seeing Clojure(Script) syntax for the first time. ClojureScript uses Lisp's s-expression syntax. This syntax has huge payoffs when you use it to build UIs. You can get a quick tour of the syntax here.


Let's dive right in..

(defn run-example-1 []
  (q/defsketch fireworks
    :host "example-1"
    :size [300 300]
    :setup setup
    :update update-fireworks
    :draw draw-fireworks
    :middleware [m/fun-mode]))


In the code above, setup is a function that returns the object that will be used to render the animation. For fireworks, this data is a simple clojure map that will be read and modified in functional style to draw the animation frames.


(defn setup []
  (q/color-mode :rgb)
  (q/frame-rate 60)
  {:particles ()
   :bombs ()
   :origin [(/ (q/width) 2)
            (q/height)]})


At its core, an animation is an infinite frame sequence looping at 60 (overridden by (q/frame-rate)) iterations per second. As shown above, the initial data is a simple map with :particles and :bombs as empty lists. The origin for the animaton is the center bottom of the canvas. We may toggle the blend mode above and see it in action. We will try and understand blend at work later.


Let's first look at update-fireworks. This is called 60 times a second. This function outlines the steps we take to enliven the animation.


;controlled from range 0 to 20 via slider
(def g-value (r/atom 5))

(defn update-fireworks [fireworks]
  ;acceleration due to gravity in pixels/frame2
  (let [g (* 0.1 @g-value)]
    (-> fireworks
        (update-particles g)
        (remove-dead-particles)
        (update-bombs g)
        (explode-bombs)
        (remove-dead-bombs)
        (add-bomb))))


The above function takes the fireworks map that we initialized in setup as argument and returns a map representing the modified fireworks map for the next iteration. This function is a 99% pure function. I have cheated inside it by referencing the global reagent atom g-value. It's a conscious sin to make the g slider user control work. A similar sin is committed to make the blend toggle work. Other than these the code is implemented as pure functions


Also, we can see that update-fireworks is coded using Clojure's thread first macro syntax. This is a Clojure style to accentuate various aspects/concerns of the animation, making data flow appear as a pipeline. The data flows neatly, sequenced step by step as a consequence. This way we can easily experiment with various possibilities, e.g we can checkout how the animation will behave in the absence of gravity or with slight variations in each of the steps.

Physics

To understand the physics applicable to a moving object in a simple way, let's see how a bomb may be represented. Note both bombs and particles are moving objects and the physics applies to both of these. We will see particles in action after we understand bombs.


{
  :bombs ({:location [150 300], :velocity 
    [-0.0043169435191359185 -15.084230483553222]}, ...)}


Everything makes sense once we follow the :velocity of an object. Velocity dictates position or :location. Acceleration and velocity are vectors in physics. Both these have magnitude and direction. As much I hate overloading the word vector, but in our Clojure code, all 3 namely acceleration (due to gravity), :velocity and :location are represented as 2 element vectors (not in the physics sense but this time in a data structure sense). All the 3 have 2 member elements each. The first element is about the horizontal (influences the x coordinate of the object) and the second element is about the vertical (influences the y coordinate). Negative y (second element of vector) in velocity means the object is heading upward. Positive y in velocity means the object is heading downward. Similarly a positive x (first element of vector) in velocity indicates a right movement and negative indiactes a left movement.


Let's get some ammo. The first step is to populate the empty list with some bombs.

(defn create-bomb [location]
  ;note bomb always moves upwards when its created.  may slide either way horizontally
  (let [h (q/height)
        v (Math/pow h 0.5)]
    {:location location
     :velocity [(q/random -4 4)
                (* v (q/random -0.85 -0.95))]}))


(defn add-bomb [fireworks]
  (let [random-number (* 5 (int (q/random 1 5)))]
    ;(print "rem test" (q/frame-count) random-number)
    ;note every 5 frames there could be a bomb that is added fastest case. But since the divisor can be 10 15 or 20 too, in reality there could be many frame multples of 5 where a bomb does not get created
    (if (zero? (rem (q/frame-count) random-number))
      (let [v (update fireworks :bombs #(conj % (create-bomb (:origin fireworks))))]   v)
      fireworks)))


The strategy is really simple. Set things up, initialize bombs (at the fastest possible rate of 1 bomb every 5 frames - we see this in action in add-bomb function above) each with the "right" velocity, create 300 particles on each bomb explosion again with the "right" velocity and finally let physics dictate the location of the bombs and particles rendered.


From the above code we can read that every 5 frames or so, a new bomb gets created with a random horizontal velocity between -4 and 4 and a slightly more involved negative (hence upward) random vertical velocity.

Bomb Explosion

(defn explode-bomb [{:keys [lifespan location velocity] :as bomb}]
  (when (> (second velocity) 0)
    (map #(create-particle location velocity) (range 300))))


From the code above we can see that the bombs explode at its highest point. When the vertical component of velocity changes sign from negative to positive.


On bomb explosion, for every bomb, 300 particles are created.

Particles

{:particles ({:location [268.9603851860678 
  109.55996483945185], 
  :velocity [2.457704565104175 
   7.1472083975787015], 
  :lifespan 75, 
  },...)}


:::clojure
(defn create-particle [location velocity]
  {:location location
   :velocity (add velocity [(* 1.5 (q/random-gaussian))
                            (* 1.5 (q/random-gaussian))])
   :lifespan 255})


From the code above we can see that the particles share the same attributes like :location and :velocity as bombs. In addition, it contains another attribute called :lifespan. Also, each particle is assigned a random velocity. It's not purely random. Its "gaussian random" to make it realistic. Many distributions in the real world follow the gaussian or normal distribution. Central Limit Theorem in probability theory dictates that variables that themselves are aggregates of many element sub-variables, exhibit a normal distribution. Like height and IQ in the human population. So our random function reflects this important aspect to make the explosion realistic. From the docs:


(q/random-gaussian) returns a float from a 
random series of numbers having a mean of 0 
and standard deviation of 1. There is 
theoretically no minimum or maximum value 
that random-gaussian might return. 
Rather, there is just a very low probability 
that values far from the mean will be returned; 
and a higher probability that numbers near 
the mean will be returned.


Looks perfect to model an explosion !!

Effect of gravity

(defn update-particle [{:keys [velocity location lifespan] :as particle}]
  (let [velocity (add velocity [0 g])]
    (assoc particle
           :velocity velocity
           :location (add velocity location)
           :lifespan (- lifespan 10))))

(defn update-bomb [{:keys [velocity location] :as bomb}]
  (let [velocity (add velocity [0 g])]
    (assoc bomb
           :velocity velocity
           :location (add velocity location))))


In the above, we can see that g is passed in via the let variable in update-fireworks with an initial value of 0.5 and gravity is the vector [0 g]. It is so because a velocity of [0 g] is added every frame This means that gravity has no influence on the horizontal movements of objects. It only influences vertical velocity and changes velocity (downward) by 0.5 every frame. In kinematics we take g to be 9.8 m/(second square) or 32 feet/(second square). For the scale of our world g = 0.5 vertical pixels/(frame square). We can play with different values of g in the slider above.


add is vector additon and is implemented as follows

(defn add [v1 v2]
  [(+ (first v1) (first v2))
   (+ (second v1) (second v2))])


Its beautiful that all Physics is realized by functions that do basic arithmetic every frame. In a sense, it implements the calculus needed for the animation through basic arithmetic. I find this a great way to appreciate the beauty and unambiguously understand the physics involved.


Also, note that the :lifespan reduces by 10 every frame. This is a crucial variable that dictates visual rendering.

Visual rendering - The calculus of colour

In school I was exposed to kinematics. With kinematics, we can only get the fireworks half done. Visual rendering is the other critical half. Visual rendering is all about manipulating each particle's colour and size over time. i.e it's all about the calculus of colour. Just as :location is determined by :velocity in classical mechanics, :size and :colour are determined by :lifespan in our calculus of colour.


Alongside the three basic color channels of red, green, and blue, which combine in various ways to create full-colour palettes, the alpha value controls the transparency of pixels. Each of r, g, b can carry a value between 0 and 255. Alpha can be expressed as a percentage, with 0 meaning fully transparent and 100 meaning fully opaque. By tweaking an object’s opacity over time, we could get the desired visual effect.


(defn draw-particle [{:keys [lifespan] [x y] :location :as particle}]
  (let [s (q/map-range lifespan 0 255 0 (/ (q/width) 100))]
    (q/fill (q/color 255 (- 255 lifespan) 0 lifespan))
    (q/ellipse x y s s)))

(defn draw-bomb [{:keys [location] :as bomb}]
  (q/fill 35)
  (let [s (/ (q/width) 100)
        [x y] location]
    (q/ellipse x y s s)))


In our implementation, "yellowness" and Alpha channel or "transparency" is determined by life span. Overall colour determined by other particles superimposed in the same location. The code above says each particle is born red. Over time it becomes more yellow and more transparent and smaller till it fades away merging with the black background on death.


Ellipses above are actually circles with radius s. Over time particles shrink from 1 percent of canvas width to 0 on death.

q/map-range is a convenient function that takes 1 range of values and scales prorated to another range. In the code above, we use it to shrink the circle's radius to become 0 as the lifespan becomes 0.

Blend mode

When 2 particles with different r g b a values overlay each other, what strategy should be employed to colour out the overlapped region? Should the top layer completely hide the bottom layer, or are there expressive ways to specify how we want the overlay to mix?


The blend mode is the answer to the above question. By saying we want the blend mode to add up, we are effectively saying that when multiple red particles share the same space, the effect is to make that space yellowish. This is great as that's precisely what happens when things heat up. It turns yellow. So a simple blend mode gets us that effect. Without having to figure out colours through complicated code. (the other possibility, brute force could be to figure out different shades of yellow and return that as a function of time to depict the temperature of hot spots - but nah that's disgustingly inelegant) Blend modes work like a charm. If blend mode is not there, the particle dies before it gets a chance to become yellow. The yellowness accelerates because of additive blending.


We use the strategy add. There are several other strategies we may play with. For fireworks, add works like a charm


Play with these to appreciate the effect of blending.

With blending


Without blending

The code for user controls

As always, user affordances become easy when we code it up in hiccup-reagent.


(defn example-2 []
  [:div
   [:div.buttons
    [:button.button.is-dark
     {:on-click
      (fn [] (print "toggling") (swap! blend? not))} "Toggle Blend"]]
   [:div.columns.is-mobile
    [:div.column.is-6
     [:input {:type "range" :value @g-value :min 1 :max 20
              :on-change (fn [e]
                           (let [new-value (js/parseInt (.. e -target -value))] (reset! g-value new-value)))}]]
    [:div.column.is-6
     [:span " "  (gstring/format " g=%0.1f" (* 0.1 @g-value))]]]])

(defn ^:export init []
  (js/console.log "init")
  (run-example-1)
  (rd/render [example-2] (.getElementById js/document "for-reagent")))


Conclusion

Putting animations together, requires a little of many things. A pinch each of Clojure(Script), Lisp, Domain modelling, Functional Programming, Physics, Statistics, Probablty Theorey, Animation, how to orthogonally layer features, how Pixels may be manipulated to make lively colours on the screen. All these contribute to making it work. Hope you had as much fun reading this as I've had putting it together.


For any queries, suggestions you may contact me by mail or ping me on my social handle.