Open 9am opened 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
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:
L
. L
to different Particles, it's possible to build a flame shape by connecting the points of the Lines with curves.
I choose to do this the 'OOP' way. Because we have several similar concept: position, velocity, particle... which are basically 2D vectors.
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.
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();
}
}
...
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);
}
}
}
...
}
Quadratic Béziers fit here but we need two endpoints EP
and a control point CP
. We'll draw the curves like this:
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();
}
}
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,
Just an optimization to draw the flame in one single path to get rid of the bar between. Now we have the flame.
Now add a control panel to it.
https://user-images.githubusercontent.com/1435457/181674038-bdbc61ac-1a8e-45fa-8581-461a72fa3b3f.mov
Well, hope you enjoy it. I'll see you next time.
@9am 🕘