9am / 9am.github.io

9am Blog 🕘 ㍡
https://9am.github.io/
MIT License
3 stars 0 forks source link

Light a 'Fire' with Canvas and Particles. #7

Open 9am opened 2 years ago

9am commented 2 years ago
A simple way to draw a dynamic fire flame on the page.
tools hits
9am commented 2 years ago

I was staring at a candle the other day, and the flame fascinated me. It forms a constant shape of light. And if you move the candle, the flame stretches like a ribbon. It would be cool to draw the flame with code. But I'm not talking about simulating the burning process, that would be another story. This is what I got:

https://user-images.githubusercontent.com/1435457/181478598-803a9822-247f-44e2-8643-3de775900153.mov

The Idea

I learned to draw ribbons on canvas a few years ago. It's a perfect way to simulate the flame shape. So the idea came up to my mind:

  1. Shoot particles in random directions from the mouse position at intervals.
  2. Apply a force that performs as the wind to the particles. If the wind goes North, it will be like a bubble gun.
  3. For each particle, draw a line perpendicular to the velocity with a length L.
  4. Apply different L to different Particles, it's possible to build a flame shape by connecting the points of the Lines with curves. idea-all

Let's do it

I choose to do this the 'OOP' way. Because we have several similar concept: position, velocity, particle... which are basically 2D vectors.

Step 1: Build the bubble gun.

We need a Vector class as the parent class, which is a fancy way of describing [x, y] with several functions to alter the x and y.

class Vector {
    constructor({ x = 0, y = 0 }) {
        this.set(x, y);
    }

    set(x, y) {
        this.x = x;
        this.y = y;
        return this;
    }

    add(v) {
        return this.set(this.x + v.x, this.y + v.y);
    }
}

And a Particle class extends Vector to describe the orange dot in the pictures above. So the [x, y] stand for the position of the particle, and we add a velocity v to Particle to make it move. The v itself is a Vector too, the x and y mean the delta value of the position per frame in each direction.

class Particle extends Vector {
    constructor({ x = 0, y = 0, v = new Vector({}) }) {
        super({ x, y });
        this.v = v;
    }

    update() {
        this.add(this.v);
    }

    render(ctx) {
        ctx.beginPath();
        ctx.arc(this.x, this.y, 4, 0, PI_2);
        ctx.closePath();
        ctx.stroke();
    }
}

Finally, a Flame class which also extends Vector. It continuously produces Particle and is in charge of updating them and rendering the flame.

class Flame extends Vector {
    constructor({ x = 0, y = 0, canvas, ...params }) {
        super({ x, y });
        this.canvas = canvas.cloneNode();
        this.ctx = this.canvas.getContext("2d");
        this.ctx.strokeStyle = "orangered";
        this.particles = new Map();
        this.pIndex = 0;
        setInterval(this.spawn, 1000 / 10);
    }

    spawn = () => {
        const p = new Particle({
            x: this.x,
            y: this.y,
            v: new Vector({
                x: Math.random() * 4 - 2,
                y: Math.random() * 4 - 2
            })
        });
        this.particles.set(this.pIndex, p);
        this.pIndex++;
    };

    update() {
        for (const [i, p] of this.particles) {
            p.update();
        }
    }

    render() {
        this.update();

        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        for (const [i, p] of this.particles) {
            p.render(this.ctx);
        }
        return this.canvas;
    }
}

Give the particles a random v and throw a requestAnimationFrame there, we have a bubble gun now.
s-1 Edit step-1

Step 2: Let the wind blow.

We need the particles to affect by a 'wind' which is a force. And this can be done through Newton’s second law. (m = 1 to make it simple)

position(t+∆t) = position(t) + ∆t * F(t) / m

It only takes about 10 particles to do the work, so we'll remove the old ones to keep a maxParticle number of particles.

class Flame {
    ...
    spawn = () => {
        ...
        if (this.pIndex > this.maxParticle) {
            this.particles.delete(this.pIndex - this.maxParticle);
        }
        ...
    };

    update() {
        for (const [i, p] of this.particles) {
            p.v.add(this.wind);
            p.update();
        }
    }
    ...

s-2 Edit step-2

Step 3: Link them.

You'll notice that the oldest particles are getting away too fast, we need them to stay close to their siblings to form a stable line. So add a maxDistance to limit the distance between them. And drawing a line perpendicular to the velocity with size gives us something like a kite.

class Particle {
    ...
    render(ctx) {
        const tv = new Vector({ len: this.size, angle: this.v.angle + PI_H });
        const a = this.addNew(tv);
        const b = this.subtractNew(tv);
        ctx.moveTo(a.x, a.y);
        ctx.lineTo(b.x, b.y);
        ...
    }
    ...
}
class Flame {
    ...
    update() {
        for (const [i, p] of this.particles) {
            const np = this.particles.get(i + 1) ?? p;
            ...
            if (p.link.length > this.maxDistance) {
                p.link.length = this.maxDistance;
                p.set(np.x + p.link.x, np.y + p.link.y);
            }
        }
    }
    ...
}

s-3 Edit step-3

Step 4.1: Get curves out of particles.

Quadratic Béziers fit here but we need two endpoints EP and a control point CP. We'll draw the curves like this:

idea-5
    render() {
        ...
        for (const [i, p] of this.particles) {
            const np = this.particles.get(i + 1) ?? p;
            const pp = this.particles.get(i - 1) ?? p;
            const pAngle = p.link.angle + PI_H;
            const npAngle = np.link.angle + PI_H;
            const ppAngle = pp.link.angle + PI_H;
            const a = pp.addNew(tv.setLenAngle(pp.size, ppAngle));
            const b = pp.subtractNew(tv);
            const c = p.addNew(tv.setLenAngle(p.size, pAngle));
            const d = p.subtractNew(tv);
            const e = np.addNew(tv.setLenAngle(np.size, npAngle));
            const f = np.subtractNew(tv);
            const ac = a.addNew(c).multiply(0.5);
            const ec = e.addNew(c).multiply(0.5);
            const bd = b.addNew(d).multiply(0.5);
            const fd = f.addNew(d).multiply(0.5);
            this.ctx.moveTo(ac.x, ac.y);
            this.ctx.quadraticCurveTo(c.x, c.y, ec.x, ec.y);
            this.ctx.lineTo(fd.x, fd.y);
            this.ctx.quadraticCurveTo(d.x, d.y, bd.x, bd.y);
            this.ctx.closePath();
            this.ctx.stroke();
        }
    }

s-4 1 Edit step-4.1

Step 4.2: Shape the ribbon.

It's kind of like a flame now but without the shape. It can be done by controlling the size of particles. If the newest particle is p0 and the oldest is p1, and by controlling the function size = ƒ(x); x ∈ [0, 1], we can shape the ribbon to the flame.

The ƒ(x) in this example: (x) => (x > 0.7) ? Math.sqrt(1 - x) * 50 : Math.pow(x - 1, 2) * -30 + 30,

curve

s-4 2 Edit step-4.2

Step 4.3: Draw curves in one path.

Just an optimization to draw the flame in one single path to get rid of the bar between. Now we have the flame.

s-4 3 Edit step-4.3

Step 5: Control the flame.

Now add a control panel to it.

  1. Change wind direction or power.
  2. Change particle numbers or distance or the frequency of spawning them.
  3. Change the color

https://user-images.githubusercontent.com/1435457/181674038-bdbc61ac-1a8e-45fa-8581-461a72fa3b3f.mov

Edit step-5

Well, hope you enjoy it. I'll see you next time.


@9am 🕘

9am commented 1 year ago

Update 2023

Check out the demo with @9am/ctrl-panel 🎉

Edit with-ctrl-panel

ctrl-panel