135. Particles – Smoke (part 2)
In this post, I create a nice column of realistic smoke, gently rising upward.
I’ll begin by saying that there is no great science or art behind this code. I have simply played and fiddled, and fiddled and played, until it came out looking reasonable. It’s not fast enough to use with a lot of other stuff at the same time, so if I were looking to actually use it in a game, I’d work on speed some more.
But it does look realistic, and even has wind effects.
When I drew explosions in the last post, each was completely separate. But now I want the particles to move together so they make a column of smoke.
So I’ve created two classes
- SmokeParticle – for individual particles of smoke
- Smoke – to manage all the particles in SmokeParticle
Let’s start with how you actually use the Smoke class, because that’s the easy part. It’s only two lines of code.
function setup() --this is the important line, creating a smoke column --at an x,y position in the middle bottom of screen s=Smoke(vec2(WIDTH/2,50)) end function draw() background(167, 188, 204, 255) s:draw() --draw smoke end
The reason it’s so simple to use is that the Smoke class makes all the decisions for you – you could of course change that if you wanted, to give the user more options.
Smoke class
The Smoke class initialises like this. See the numbered lines and notes underneath.
function Smoke:init(pos) self.pos=pos --particle settings -- self.p={} --particle properties are held in here --[1] particle variations self.size=vec2(200,300) --pixels self.expandTime=vec2(70,80) --seconds self.fadeTime=vec2(10,20) --seconds -texture image-- local w=512 --size of image used as texture self.img=Smoke.createImage(w) --[2] get a piece of the big image --[3] create base particle that will just sit at the source --and mask the new particles self.base=SmokeParticle(self.pos,self.img,vec2(15,15), vec2(5,5),vec2(0,0)) --[4] set time to first new particle self.timer=0 self.wind=math.random()*0.3-0.15 --[5] horizontal wind speed self.windShift=math.random(10,20) --[6] time to next wind shift end
[1] We want particles to vary in size and in how fast they expand and shrink, so the smoke doesn’t look too artificial. So we provide a minimum and maximum value for each of size, time spent expanding, and time spent contracting. When we create individual particles, random values will be chosen in these ranges of values. I’ve stored the values as vec2.
[2] createImage is exactly the same as in the previous post. It creates the noise image we will use as the texture for all our particles.
[3] When I started playing with this, I had the problem that my particles started out really small and grew gradually, which is fine, except the very bottom of my fire was obviously very small and looked odd. So here, I’m creating a particle which I’m putting at the starting point of the fire, and setting it so it never fades. This means all the other particles will start off inside this particle, and then drift upward out of it as they get bigger. So I will always have a decent sized smoke ball at the bottom of the smoke column.
[4] We’ll start adding new particles using a timer, as you will see.
[5] The wind acts like a force (as discussed in earlier physics posts), pushing the particles left or right. Here I choose a random wind strength and direction.
[6] The wind can change direction every now and then, so I set a timer to the next change.
Now the drawing part. It’s not too complicated. Again, see the notes.
function Smoke:draw(wind) --create new particle when timer gets to 0 self.timer=self.timer-DeltaTime [1] if self.timer<0 then local p=SmokeParticle(self.pos,self.img,self.size, self.expandTime,self.fadeTime) table.insert(self.p,p) self.timer=2+math.random()*2 [2] end --reset wind shift when timer reaches 0 self.windShift=self.windShift-DeltaTime [3] if self.windShift<0 then self.wind=math.random()*0.3-0.15 --horizontal wind speed self.windShift=math.random()*10+20 end self.base:draw(vec2(0,0)) --draw base particle --draw other particles --y value is vertical drift speed local velocity=vec2(self.wind,0.5) --remove particle if faded completely [4] for i,p in pairs(self.p) do if p:draw(velocity)==false then table.remove(self.p,i) end end end
[1] We reduce the timer by the number of seconds since we last drew, and if it gets to zero, it’s time for a new particle
[2] we reset the timer after adding a new particle
[3] If the timer for the wind gets to zero, change direction randomly
[4] remove particles when they fade
SmokeParticle class
This chunk of code looks rather long, but most of it is just creating vertices for the mesh, and if you’ve done that before, the rest of the code is pretty easy, and the comments in the code should make things clear.
--pos = vec2(x,y) starting position of particle --img = noise image we are going to take a piece from --vecSize = vec2(x,y) = size of the image piece --vecExpand = vec2(x,y) = min/max expand time --vecFade = vec2(x,y) = min/max fade time function SmokeParticle:init(pos,img,vecSize,vecExpand,vecFade) self.pos=vec2(pos.x,pos.y) --current x,y position self.source=vec2(pos.x,pos.y) --starting position --particle settings -- --choose random size and timing based on ranges given local size=math.random(vecSize.x,vecSize.y) self.expandTime=math.random(vecExpand.x,vecExpand.y) self.fadeTime=math.random(vecFade.x,vecFade.y) --create mesh -- self.m=mesh() self.m.texture=img local w=img.width local x1,y1,x2,y2=-size/2,-size/2,size/2,size/2 --choose a random piece of the noise picture as texture local tx1=math.random()*(1-size/w) local ty1=math.random()*(1-size/w) local tx2,ty2=tx1+size/w,ty1+size/w v,t={},{} v[1]=vec2(x1,y1) t[1]=vec2(tx1,ty1) v[2]=vec2(x2,y1) t[2]=vec2(tx2,ty1) v[3]=vec2(x2,y2) t[3]=vec2(tx2,ty2) v[4]=vec2(x1,y2) t[4]=vec2(tx1,ty2) v[5]=vec2(x1,y1) t[5]=vec2(tx1,ty1) v[6]=vec2(x2,y2) t[6]=vec2(tx2,ty2) self.m.vertices=v self.m.texCoords=t self.m:setColors(color(255)) self.m.shader=shader(smokeShader.vertexShader, smokeShader.fragmentShader) self.m.shader.size=size/w --see how fade and centre (below) are used in draw function self.m.shader.fade=1 --0=invisible, 1=full alpha self.m.shader.centre=vec2((tx1+tx2)/2,(ty1+ty2)/2) self.timer=0 end
Finally, the drawing of each particle. This is the important function. I think you should be able to follow the logic. And you are welcome to improve any of it.
function SmokeParticle:draw(velocity) self.timer=self.timer+DeltaTime --calculate how big it has got self.m.shader.frac=math.max(0.04,math.min(1,self.timer/ (self.expandTime+self.fadeTime))) --if fading, calculate fade and return false if fade=0 if self.timer>self.expandTime and self.fadeTime>0 then self.m.shader.fade=1-(self.timer-self.expandTime) /self.fadeTime if self.m.shader.fade<0 then return false end end pushMatrix() --wind takes effect as smoke rises, phase over 100 pixels self.pos.x=self.pos.x+velocity.x* math.min(1,(self.pos.y-self.source.y)/100) --make smoke drift upwards slowly, then increasing self.pos.y=self.pos.y+velocity.y*math.min(1,self.timer/50) if self.pos.y>HEIGHT then return false end translate(self.pos.x,self.pos.y) self.m:draw() popMatrix() return true end