Close

December 17, 2016

Making Fire I

I saw a really cool lamp on YouTube which is not for sale, so I thought I'd try to make something like it using the Raspberry Pi.

Since I'm a programmer and not an electrical engineer I thought I'd start with the fun part first - programmatic Fire using a custom particle generator.

My Pi will be running node.js which mainly uses JavaScript, so the easiest way to get started was to pick a JavaScript library available on node (paper.js) for my simulated RGB display. I had to be careful here and separate my particle emission logic from my display logic so that modifying it for the physical display later will be easier.

This project consists of two main parts so far, the particle system and the display loop. The display loop simply reads the state of the particle system and renders it out. The particle system maintains its own time on all the objects and makes sure the kinematics for the particles are updated on a per-tick basis. Doing it this way will make it a whole lot easier to adapt to an RGB LED display later (since I basically need to compute density per LED, rather than pixels.)

Since this is part one and more or less hacked together, here's a PoC that a JavaScript particle generator might work. Please ignore the magic numbers.

Here's the result of the first session's testing:



Here's the code if you're interested!



Main HTML File:

<!DOCTYPE html>
<html>
<head>
    <!-- Load the Paper.js library -->
    <script type="text/javascript" src="js/paper-full.js"></script>

    <!-- Load external PaperScript and associate it with myCanvas -->
    <script type="text/paperscript" src="js/drawlayer.js" canvas="myCanvas">
    </script>

    <!-- Define inlined JavaScript -->
    <script type="text/javascript">

        // Global collections
        var emberCollection = [];

        var windCollection = [];


        // Magic numbers
        var maxPosition = 5; // depends on how many particles there are...
        var magicAccel = -0.001;
        var magicFramerate = 50;
        var magicZones = [0.17, 0.15, 0.10, 0.05];
        var maxEmbers = 80;
        var firewidth = 0.6;
        var firespread = 0.2;
        var firebaseheight = 0.7;
        var embersPerTick = 5;


        var ticks = 0;
        // Only executed our code once the DOM is ready.
        window.onload = function() {


            class Wind {
                constructor(position_y, acceleration_x) {
                    this.position = position_y; // 0 - 1, height of window
                    this.acceleration = acceleration_x;
                }
            }

            windCollection.push(new Wind(0.15, 0));


            // Fire comes from the bottom and rises
            // wind changes acceleration left or right


            class Ember {
                constructor(x, y) {
                    this.startposition = new paper.Point(x, y);
                    this.startvelocity = new paper.Point(0, 0);
                    this.acceleration = new paper.Point(0, magicAccel); 
// acceleration numbers are arbitrary, units of (screen height)
                    this.time = 0;
                    this.symbol = null;
                    this.scale = 1;

                    this.debugcolor = "green"//f4bc42

                    this.decayresistence = 0.2;// add less scaling
                    this.scaleboost = 0.0;// add less scaling
                }



                get position() {
                    return new paper.Point(this.startposition.x + 
                             this.time*this.startvelocity.x + 0.5 * 
                             this.time*this.time*this.acceleration.x,
                            (this.startposition.y + 
                             this.time*this.startvelocity.y +
                             0.5 * this.time*this.time*this.acceleration.y ) 
                             / maxPosition);
                }

                applyNewAcceleration(newAcceleration) {

                    this.startvelocity = new paper.Point(this.startvelocity.x + 
                                          this.acceleration.x*this.time, 
                                          this.startvelocity.y + 
                                          this.acceleration.y*this.time);
                    this.startposition = this.position;
                    this.acceleration.x += newAcceleration.x;
                    this.acceleration.y += newAcceleration.y;

                    this.time = 0;
                }



                recycle(into) {
                    console.log("Max "+this.position)

                    this.startposition = into.startposition;
                    this.startvelocity = into.startvelocity;
                    this.acceleration = into.acceleration;
                    this.time = 0;
                    this.scale = 1;
                    if (this.symbol != null)
                        this.symbol.remove();
                    this.symbol = null;
                    this.debugcolor = "red";

                }
            }



            var ttl = 0;

            function tickFn(){
                ticks++;
                // spawn new embers up to 100 then overwrite them

                var newEmberCount = Math.random() * embersPerTick ;
                var ct = emberCollection.length;

                for (var i = 0; i < newEmberCount; i++){
                    ttl++;

                    var smallrand =Math.random()*firespread;
                    // Recycle the old ones
                    var newEmber = new Ember(firewidth - smallrand, 
                                          firebaseheight+Math.random() * 
                                          (firebaseheight/2)); 
                          // want it near center, so 0.5 +- 0.1

                    // Sam doesn't like the start velocity
                    //newEmber.startvelocity = new paper.Point(0.0025 - 
                                             Math.random()*0.005, 
                                             0.0025-Math.random()*0.005);

                    newEmber.acceleration.x += (smallrand - 0.1)/1000;

                    // Should trend back towards center...

                    var newIndex = ct+i;

                    if (newIndex >= maxEmbers){
                        // recycle the symbol from the old object
                        // since we want a fixed number of max objects
                        emberCollection[ttl % maxEmbers].recycle(newEmber);
                    }
                    else{
                        emberCollection[newIndex] = newEmber;
                    }
                }


                for (var j = 0; j < emberCollection.length; j++){
                    var oldPos = emberCollection[j].position;
                    emberCollection[j].time++;
                    var newPos = emberCollection[j].position;


                    if (newPos.y > magicZones[0]){
                        emberCollection[j].debugcolor = "firebrick";

                    }
                    else if (newPos.y > magicZones[1]){
                        emberCollection[j].debugcolor = "red";

                    }
                    else if (newPos.y > magicZones[2])
                    {
                        emberCollection[j].debugcolor = "orange";
                    }
                    else if (newPos.y > magicZones[3])
                    {
                        emberCollection[j].debugcolor = "yellow";
                    }
                    else
                    {
                        emberCollection[j].debugcolor = "grey";
                    }



                }

            }

            // Could have periodic flares where the scale increases for 1 frame


            // idea - crosswinds which change and add x acceleration, have to 
            //  sum the accel and reset time? or time traveled per acceleration?
            // each time accel changes, snapshot current velocity, add new 
            // acceleration, reset time to 0?

            window.setInterval(function() {
                tickFn();
            }, magicFramerate);

        }
    </script>
</head>
<body>
<canvas id="myCanvas" style="width:100%; height: 100%; background:black;" resize>
</canvas>
</body>
</html>

Draw Loop:

/**
 * Created by Nick on 12/14/2016.
 */

var paperEmbers = emberCollection; // Link back to globals... this is sloppy

var lastTick =0;

// The onFrame function is called up to 60 times a second:
function onFrame(event) {

    if (lastTick == ticks)
        return; // Don't redraw the same "frame"

    lastTick = ticks;


    for (var i  =0; i < paperEmbers.length; i++){
        if (paperEmbers[i].symbol == null){
            paperEmbers[i].symbol = new Path.Circle({
                center: paperEmbers[i].startposition * view.size,
                radius: view.size.width*0.025,
                fillColor: '#f4bc42'
            });
        }

        // translate the points from the model into points in the view
        paperEmbers[i].symbol.position = paperEmbers[i].position* view.size *
                                         (new Point(1,maxPosition));

        // Set the fill color based on the stored property
        paperEmbers[i].symbol.fillColor = paperEmbers[i].debugcolor;

        // problem - scale is cumulative
        // solution - always reverse the previous action. This creates rounding 
                      // errors but since embers are short lived
        //            it doesn't matter
        if (Math.abs(paperEmbers[i].symbol.position.y) < view.size.height) {
            var oldScale = paperEmbers[i].scale;
            paperEmbers[i].scale = Math.abs(paperEmbers[i].symbol.position.y) 
                                   / view.size.height + paperEmbers[i].scaleboost;
            paperEmbers[i].symbol.scale(1/oldScale * paperEmbers[i].scale); 
                         // reverse the old scale and apply a new one

        }

    }
}