This repository has been archived by the owner on Mar 8, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 143
Downwell Trails #9
Comments
a327ex
changed the title
Programming 2D Visual Effects - Downwell Trails
Downwell Trails
Jan 1, 2016
Wonderful blog post, @adonaac ! Keep them coming! |
Thanks! |
Just a quick note: the link for the game is broken (relative instead of absolute). Great post! |
cool post |
@radgeRayden Thanks for pointing that out, fixed it @leafo thx |
👍 |
This was referenced Jan 3, 2016
@adonaac edit: BTW, it seems your twitter link is broken. |
It's so helpful, thank you. |
Great tutorials, thanks for sharing, @SSYGEN ! I have an off-topic question... How do you record your gifs? |
Amazing post ! Loved it :D |
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
2016-01-01 07:05
This post will explain how to make trails like the ones in Downwell from scratch using LÖVE. There's another tutorial on this written by Zack Bell here. If you don't know how to use LÖVE/Lua but have some programming knowledge then this tutorial might also serve as a learning exercise on LÖVE/Lua itself.
LÖVE
Before starting with the actual effect we need to get some base code ready, basically just an organized way of creating and deleting entities as well as drawing them to the screen. Since LÖVE doesn't really come with this built-in we need to build it ourselves.
The first thing to do is create a folder for our project and in it a
main.lua
file with the following contents:When a LÖVE project is loaded,
love.load()
is called on startup once and thenlove.update
andlove.draw
are called every frame. To run this basic LÖVE project you can check out the Getting Started page. After you manage to run it you should see a black screen.GameObject
Now, we'll need some entities in our game and for that we'll use an OOP library called classic. Like the github page says, after downloading the library and placing the classic folder in the same folder as the
main.lua
file, you can require it by doing:Now
Object
is a global variable that holds the definition of the classic library, and with it you can create new classes, likePoint = Object:extend()
. We'll use this to create aGameObject
class which will be the main class we use for our entities:Since we defined this in
GameObject.lua
, we need to require that file in themain.lua
script, otherwise we won't have access to this definition:And now
GameObject
is a global variable that holds the definition of theGameObject
class. Alternatively, we could have defined theGameObject
variable locally inGameObject.lua
and then made it a global variable inmain.lua
, like so:Sometimes, like when writing libraries for other people, this is a better way of doing things so you don't pollute their global state with your library's variables. This is what classic does as well, which is why you have to initialize it by assigning it to the
Object
variable. One good result of this is that since we're assigning a library to a variable, if you wanted to you could have namedObject
asClass
instead, and then your class definitions would look likeGameObject = Class:extend()
.Finally, a
GameObject
needs to have some properties. One simple setup that I've found useful was to make it so that the constructor calls for all my objects follow the same pattern ofClassName(x, y, optional_arguments)
. I've found that all objects need to have some position (and if they don't then that can just be0, 0
) and thenoptional_arguments
is a table with as many optional arguments as you want it to have. For ourGameObjects
this would look like this:And so in this example you'd create a
GameObject
instance calledgame_object
with those attributes. You can read up more about how thefor
in the constructor works here in the Objects, attributes and methods in Lua section.Another thing from the code above is the usage of the
print
function. It's a useful tool for debugging but you need to be running your application from a console, or if you're on Windows, whatever hooks you have created to run your project, you need to add the--console
option so that a console shows up andprint
statements are printed there.print
statements will not be printed on the main game screen and it's kind of a pain to debug things and tie them to the main screen, so I highly recommend figuring out how to make the console show up as well whenever you run the game.Object Creation and Deletion
The trail effect is achieved by creating multiple trail instances and quickly deleting them (like after 0.2 seconds they were created), so because of that we need some logic for object creation and deletion. Object creation was shown above, the only additional step we need is adding the object to a table that will hold all of them (
table.insert
):So with this setup I created a function called
createGameObject
, which creates a newGameObject
instance and adds it to thegame_objects
table. The objects in this table are being updated and drawn every frame inlove.update
andlove.draw
respectively (and they need to have both those functions defined). If you run this example nothing will happen still, but if you changeGameObject:draw
a bit to draw something to the screen, then you should see it being drawn (love.graphics.circle
):For deleting objects we need a few extra steps. First, for each
GameObject
we need to create a new variable calleddead
which will tell you if thisGameObject
is alive or not:Then, we need to change our update logic a bit to take into account dead entities, and making sure that we remove them from the
game_objects
list (table.remove
):And so whenever a
GameObject
instance has itsdead
attribute set totrue
, it will automatically get removed from thegame_objects
list. One of the important things to note here is that we're going through the list backwards. This is because in Lua, whenever something is removed from a list it gets resorted so as to leave nonil
spaces in it. This means that if object1
and2
need to be deleted, if we go with a normal forwards loop, object1
will be deleted, the list will be resorted and now object2
will be object1
, but since we already went to the next iteration, we'll not get to delete the original object2
(because it's in the first position now). To prevent this from happening we go over the list backwards.To test if this works out we can bind the deletion of a single instance we created to a mouse click (
love.mousepressed
):Finally, one thing we can do with the mouse is binding
game_object
's position to the mouse position (love.mouse.getPosition
):Screen Resolution
Before we move on to making the actual trails and getting into the meat of this, we need to do one last thing. A game like Downwell has a really low native resolution that gets scaled up to the size of the screen, and this creates a good looking pixelated effect on whatever you draw to the screen. The default resolution a LÖVE game uses is
800x600
and this is a lot higher than what we need. So to decrease this to320x240
what we can do is play withconf.lua
, LÖVE's configuration file, and change the default resolution to our desired size. To do this, create a file namedconf.lua
at the same level thatmain.lua
is in, and fill it with these contents (conf.lua
):Now, to scale this up to, say,
960x720
(scaling it up by 3) while maintaining the pixel size of320x240
, we need to draw the whole screen to a canvas and then scale that up by 3. In this way, we'll always be working as if everything were at the small native resolution, but at the last step it will be drawn way bigger (love.graphics.newCanvas
):First we create a new canvas,
main_canvas
, with the native size and set its filter tonearest
(so that it scales up with the nearest neighbor algorithm, essential for pixel art). Then, instead of just drawing the game objects directly to the screen, we setmain_canvas
withlove.graphics.setCanvas
, clear the contents from this canvas from the last frame withlove.graphics.clear
, draw the game objects, and then draw the canvas scale up by 3 (love.graphics.draw
). This is what it looks like:Compared to what it looked like before the pixel art effect is there, so it seems to be working. You might wanna also make the window itself bigger (instead of 320x240), and you can do that by using
love.window.setMode
:If you're following along by coding this yourself you might have noticed that the mouse position code is now broken. This is because
love.mouse.getPosition
works in based on the screen size, and if the mouse is now on the middle of the screen480, 360
, this is bigger than320, 240
, which means the circle won't be drawn on the screen. Basically because of the way we're using the canvas we only work in the320, 240
space, but the screen is bigger than that. To really solve this we should use some kind of camera system, but for this example we can just do it the quick way and divide the mouse position by 3:Timing and Multiple Object Types
Now we have absolutely everything we need to actually make the trail effect. That was a lie. First we need a timing library. This is because we need an easy way to delete an object
n
seconds after it has been created and an easy way to tween an object's properties, since this is what trails generally do. To do this I'll use HUMP. To install it, download it and place it on the project's folder, then require the timer module:Now we can create an instance of a timer to use. I find it a good idea to create one timer per object that needs a timer, but for this examples it's fine it we just use a global one:
We can test to see if the timer works by using one of its functions,
after
. This function takes as arguments a numbern
and a function which is executed aftern
seconds:And so in this example
game_object
will be deleted after the game has run for4
seconds.And this is finally the very last thing we need to do before we can actually do trails, which is defining multiple object types. Each trail object (that will get deleted really fast) will need to be an instance of some class, but it can't be the
GameObject
class, since we want that class to have the behavior that follows the mouse and draws the circle. So now what we need to do is come up with a way of supporting multiple object types. There are multiple ways of doing this, but I'll go with the simple one:All we changed in this function is accepting a
type
variable and then using that with_G
._G
is the table that holds all global state in Lua. Since it's just a table, you can access the values in it by using the appropriate keys, which happen to be the names of the variables. So for instance_G['game_object']
contains theGameObject
instance that we've been using so far, and_G['GameObject']
contains theGameObject
class definition. So whenever usingcreateGameObject
instead of callingcreateGameObject(x, y, opts)
we now have to docreateGameObject(class_name, x, y, opts)
:When we add the trail objects all we need to do to create them instead of a
GameObject
is changing the first parameter we pass tocreateGameObject
.Trails
Now to make trails. One way trails can work is actually pretty simple. You have some object you want to emit some sort of trail effect, and then every
n
seconds (like 0.02) you create aTrail
object that will look like you want the trail to look. ThisTrail
object will usually be deleted very quickly (like 0.2 seconds after its creation) otherwise you'll get a really really long trail. We have everything we need to do that now, so let's start by creating theTrail
class:The first 4 lines of the constructor are exactly the same as for
GameObject
, so this is just standard object attributes. The important part comes in the next line that uses the timer. Here all we're doing is deleting this trail object0.15
seconds after it has been created. And in the draw function we're simply drawing a circle and usingself.r
as its radius. This attribute will be specified in the creation call (viaopts
) so we don't need to worry about it in the constructor.Next, we need to create the trails and we'll do this in the
GameObject
constructor. Everyn
seconds we'll create a newTrail
instance at the currentgame_object
position:So here every
0.01
seconds, or every frame, we're creating a trail atself.x, self.y
withr = 25
. One important thing to realize is thatself.x, self.y
inside that function is always up to date with the current position instead of only theself.x, self.y
values we have in the constructor. This is because that function is getting called every frame and because of the way closures work in Lua, that function has access toself
, andself
is always being updated so it all works out. And that should look like this:Not terribly amazing but we're getting somewhere.
Downwell Trail Effect
To make the Downwell trail effect we want to erase lines from the trail only, either horizontally or vertically. To do this we need to separate the drawing of the main game object and the drawing of the trails into separate canvases, since we only want to apply the effect to one of those types of objects. The first thing we need to do to get this going is being able to differentiate between which objects are of which class when drawing, and currently we have no way of doing that. Instances of classes created by classic have no default attributes magically set to them that say "I'm of this class", so we have to do that manually.
All we've done here is added the
type
attribute to all objects, so that now we can do stuff likeif object.type == 'Trail'
and figure out what kind of object we're dealing with.Now before we separate object drawing to different canvases we should create those:
Those creation calls are exactly the same as the ones we used for creating our
main_canvas
. Now, to separate trails and the game object, we need to draw the trails totrail_canvas
, draw the game object togame_object_canvas
, draw both of those to themain_canvas
, and then drawmain_canvas
to the screen while scaling it up. And that looks like this:There's totally some dumb stuff going on here, like for instance we're going over the list of objects twice now (even though we're not drawing twice), and that could totally be optimized. But really for an example this small this doesn't matter at all. What's important is that it works!
So now that we've separated different things into different render targets we can move on with our effect. The way to erase lines from all the trails is to draw lines to the trail canvas using the
subtract
blend mode. What this blend mode does is literally just subtract the values of the thing you're drawing from what's already on the screen/canvas. So in this case what we want is to draw a bunch of white lines(1, 1, 1, 1)
withsubtract
enabled totrail_canvas
(after the trails have been drawn), in this way the places where those lines would appear will become(0, 0, 0, 0)
(love.graphics.setBlendMode
):And this is what that looks like:
One important thing to do before drawing this is to set the line style to
'rough'
usinglove.graphics.setLineStyle
, since the default is'smooth'
and that doesn't work with the pixel art style generally. And if you didn't really understand what's going on here, here's what the lines by themselves would look like if they were drawn normally:So all we're doing is subtracting that from the trail canvas, and since the only places in the trail canvas where there are things to be subtracted from are the trails, we get the effect only there. Another thing to note is that the
subtract
blend mode idea would only work if you have a black background. For instance, I tried this effect in my game and this is what it looks like:If I were to use the subtract blend mode here it just would look even worse than it does. So instead what I did was use
multiply
. Like the name indicates, it just multiplies the values instead of subtracting them. So the white lines(1, 1, 1, 1)
won't change the output in any way, while the gaps(0, 0, 0, 0)
will make whatever collides with them transparent. In this way you get the same effect, the only difference is that with subtract the white lines themselves result in transparency, while with multiply the gaps between them do.Details
Now we already have the effect working but we can make it more interesting. For starters, we can randomly draw extra lines so that it creates some random gaps in the final result:
So here every
0.1
seconds, for every line we're drawing, we set some values to true or false to an additional table,trail_lines_extra_draw
. If the value for some line in this table is true, then whenever we get to drawing that line, we'll also draw the another line 1 pixel to its right. Since we're looping over lines on a 2 by 2 pixels basis, this will create a section where there are 3 lines being drawn at once, and this will create the effect of a few lines looking like they're missing from the trail. You can play with different chances and times (every0.05
seconds?) to see what you think looks best.Now another thing we can do is tween the radius of the trail circles down before they disappear. This will give the effect a much more trail-like feel:
I deleted the previous
timer.after
call that was here and changed it for this newtimer.tween
. The tween call takes a number of seconds, the target table, the target value inside that table to be tweened, the tween method, and then an optional function that gets called when the tween ends. In this case, we're tweeningself.r
to0
over0.3
seconds using thelinear
interpolation method, and then when that is done we kill the trail object. That looks like this:Another thing we can do is, every frame, adding some random amount within a certain range to the radius of the trail circles being drawn. This will give the trails an extra bit of randomization:
Here we just need to define a function called
randomp
, which returns a float betweenmin
andmax
. In this case we use it to add, to the radius of every trail, every frame, a random amount between-2.5
and2.5
. And that looks like this:Something else that's possible is rotating the angle of the lines based on the angle of the velocity vector of whatever is generating the trails. To do that first we need to figure out the velocity of our game object. An easy way to achieve this is storing its previous position, then subtracting the current position from the previous one and getting the angle of that:
To calculate the current angle we use the current
self.x, self.y
and the values forx, y
from the previous frame. Then after that, at the end of the update function, we set the values of the current frame to theprevious
variables. If you want to test if this actually works you can tryprint(math.deg(self.angle))
. Keep in mind that LÖVE uses a reverse angle system (up from 0 is negative).After we have the angle we can try to rotate the lines being drawn like this (
push
,pop
):pushRotate
is a function that will make everything drawn after it rotated byr
with the pivot position beingx, y
. This is useful in a lot of situations and in this instance we're using it to rotate all the lines by the angle ofgame_object
. I addedmath.pi/2
to the angle because it came out sideways for whatever reason... Anyway, that should look like this:Not really sure if it looks better or worse, looks like it's fine for slow moving situations but when its too fast it can get a bit chaotic (probably because I stop the object and then the angle changes abruptly midway).
One problem with this way of doing things is that since all the lines are being rotated but they're being drawn at first to fit the screen, you'll get places where the lines simply don't exist anymore and the effect fails to happen, which causes the trails to look all white. To prevent this we can just draw lines way beyond the screen boundaries on all directions, such that with any rotation happening lines will still be drawn anyway:
Finally, one last cool thing we can do is changing the shape of the main ball and of the trails based on its velocity. We can use the same idea from the example above, except to calculate the magnitude of the game object's velocity instead of the angle:
Vector
was initialized inmain.lua
and it comes fromHUMP
. I'll omit that code because you should be able to do that by now. Anyway, here we calculate a value calledvmag
. This value is basically an indicator of how fast the object is moving in any direction. Since we already have the angle the magnitude of the velocity is all we really need. With that information we can do the following:And that looks like this:
Let's go part by part. First the
map
function. This is a function that takes some value namedold_value
, two values namedold_min
andold_max
representing the range thatold_value
can take, and two other values namednew_min
andnew_max
, representing the desired new range. With all this it returns a new value that corresponds toold_value
, but if it were in thenew_min
,new_max
range. For instance, if we domap(0.5, 0, 1, 0, 100)
we'll get50
back. If we domap(0.5, 0, 1, 200, -200)
we'll get0
back. If we domap(0.5, 0, 1, -200, 100)
we'll get-50
back. And so on...We use this function to calculate the variables
self.xm
andself.ym
. These variables will be used as multiplication factors to change the size of the ellipse we're drawing. One thing to keep in mind is that when drawing the ellipse we're first rotating everything byself.angle
, this means that we should always consider what we want to happen when drawing as if we were at angle0
(to the right), because all other angles will just happen automatically from that.Practically, this means that we should consider the changes in shape of our ellipse from a horizontal perspective. So when the game object is going really fast we want the ellipse to stretch horizontally and shrink vertically, which means that the values we find for
self.xm
andself.ym
have to reflect that. As you can see, forself.xm
, whenself.vmag
is0
we get1
back, and when it's20
we get2
, meaning, whenself.vmag
is20
we double the size of the ellipse horizontally, bringing it up to30
. Forself.vmag
values greater than that we increase it even more. Similar logic applies toself.ym
and how it shrinks.It's important to note that those values (
0
,20
,2
,0.25
) used in those two map functions up there were reached by trial and error and seeing what looks good. Most importantly, they're really exaggerated so that the effect can actually be seen well enough in these gifs. I would personally go with the values0
and60
instead of20
if I were doing this normally.Finally, we can also apply this to the
Trail
objects:We just change the way trails are being drawn to be the same as the main game object. We also make sure to send the trail object the information needed (
self.xm, self.ym, self.angle
), otherwise it can't really do anything. And that looks like this:There are a few bugs like the velocity going way too big and the
self.ym
value becoming negative, making each ellipse really huge, but this can be fixed by just doing some checks. Also, sometimes when you go kinda fast with abrupt angle changes you can see the shapes of each ellipse individually and that looks kinda broken. I don't know exactly how I'd fix that other than not having abrupt angle changes on an object with that kind of trail effect.END
That's it. I hope we've all learned something about friendship today.
The text was updated successfully, but these errors were encountered: