Easy Animations in DragonRuby with Enumerators
I’ve always struggled with doing programmatic animations in game engines, and DragonRuby is no exception. The result is always very math heavy, easy to screw up, and hard to understand even after you get it working. Say you want to animate a sprite moving back and forth with the following parts to the animation:
- keep the sprite stationary on the left for 0.5 seconds
- over the next 1 second, smoothly move the sprite to the right
- keep the sprite stationary on the right for 0.5 seconds
- over the next 1 second, smoothly move the sprite to the left
- repeat
Doing that using the built-in animation functions in DragonRuby involves adding
code like this to your tick
method:
class Numeric
# linear interpolate from start to finish as this number varies from 0 to 1
def lerp start, finish
((finish - start) * self + start).to_f
end
end
# determine total animation length ahead of time
total_duration = 3.seconds
# mod the tick count so animation will repeat
t = args.state.tick_count % total_duration
# first, guess that we're in the left-to-right part
progress = args.easing.ease(
0.5.seconds, # delay before this part of the animation
t, # tick count (modified, since we're looping)
1.seconds, # duration of this part of animation
easing_func # a proc to smooth the animation (more on this later)
)
args.state.sprite_pos = progress.lerp(320, 960)
# if that part is done, we must be in the right-to-left part
if progress >= 1
progress = args.easing.ease(
2.seconds, # delay before animation, taking into account the previous parts
t, # remaining args same as before
1.seconds,
easing_func
)
args.state.sprite_pos = progress.lerp(960, 320)
end
This sucks.
If you’ve never struggled with this stuff before, I’ll spell it out. It sucks that we have to calculate the overall animation duration ahead of time; it sucks that we have to calculate a cumulative delay for each additional part of the animation; it sucks that we need an explicit mechanism for determining which part of the animation we should be in; it sucks that each frame has to re-compute parts of the animation that aren’t active; it sucks that this code will get harder and harder to reason about as we add more phases to the animation; and above all it sucks that our original human-readable description of the animation is now completely hidden in the code. Good luck reading this tomorrow and understanding how it effects the desired behavior.
But two months ago, I stumbled upon a blog post about how to solve this sort of problem in Unity. I was curious if there’s an analogus way to apply the technique to DragonRuby and I finally got the time to figure it out.
Enumerator
First, a brief diversion into a very cool part of Ruby: enumerators. In Ruby we have simple loops:
i = 0
loop do
puts i
i = 3 * i + 4
break if i > 100
end
# outputs 0, 4, 16, 52
But loops are all-or-nothing: once you start running them you have to wait for
them to finish… or do you? Enter Enumerator
, with this you get fine-grained
lazy evaluation:
i = 0
e = Enumerator.new do |yielder|
loop do
yielder << i
i = 3 * i + 4
break if i > 100
end
end
e.next # 0
e.next # 4
e.next # 16
e.next # 52
e.next # raises StopIteration
This is magic! It looks misleadingly simple because the Ruby interpreter is
doing some cool stuff under the hood for us. That yielder << i
line is the
real key: that’s where the interpreter suspends execution of the block and saves
it to memory. The enumerator remains there, dormant, until we call next
and it
briefly springs to life until the next yielder << i
line is hit. The other key
part is that the enumerator is constructed with a block so, like all blocks, it
captures its local scope. That means the i
variable remains accessible to it.
That would be the case even if we were to return the enumerator from this scope:
the local variable would be gone but the enumerator asleep on the heap would
still have a reference to it until it is done enumerating.
This is exactly what we need to solve our animation problem.
eease
A simple tool:
def ecount duration, &blk
enum = (0...duration).each
return enum unless blk
enum.lazy.map &blk
end
ecount(30) # construct an enumerator that yields 0..29
ecount(2.seconds) # construct an enumerator that yields 0..119
In DragonRuby, x.seconds == x * 60
since it runs at a constant 60 FPS.
Maybe you see where this is going?
# in tick
args.state.animation ||= ecount(2.seconds) { |i|
puts "Animation is on frame #{i}"
}
begin
args.state.animation.next
rescue StopIteration
# animation is done
end
By calling next
each frame, this enumerator now represents a real-time
animation (currently just “animating” some text into the console, but you get
the idea)! Already this solves a lot of our problems:
- We no longer need to externally manage the duration of the animation. It
remembers what duration it was initialized with and it tells us when it is
done (via
StopIteration
) - This doesn’t require precomputing an absolute start-time based on the tick
count. The animation starts whenever you first call
next
- This no longer wastes time on previous stages of animation. Ruby pauses the enumerator between frames and resumes it right where it left off
- We can easily construct an array of enumerators to manage a large collection of simultaneous animations, all running independently
But we’re not done yet. Now that we can easily start independent animations, DragonRuby’s convention of basing animations on tick counts is too restrictive. Many animations don’t care how many tick counts came before they started or what tick count it is now, they only care about how far along they should be in their particular state change. So a new tool is needed:
def eease duration, easing_func, &blk
Enumerator.new do |yielder|
if duration == 1
yielder << blk[easing_func[1.0]]
else
last_i = (duration - 1).to_f
(0...duration).each do |i|
yielder << blk[easing_func[i / last_i]]
end
end
end
end
This is similar to ecount
except we always yield a value from 0 to 1,
representing the progress through the animation, passed through an easing
function. An easing function is a function that takes an input from 0 to 1 and
outputs something from 0 to 1, such as GTK::Easing.quad
. The idea is that
eease
is the enumerator version of DragonRuby’s ease
.
Instead of taking a start time it starts whenever you first call next
, it
doesn’t need a tick count because it tracks that internally, it still gets a
duration and an easing function, and instead of returning its progress, it yields
it so you can encapsulate the entire animation in the construction of the
enumerator. Using it looks like this:
args.state.animation ||= eease(2.seconds, GTK::Easing.method(:quad)) { |t|
# you'll see the progress slowly accelerate over time, due to Easing.quad
puts "Animation is #{(t * 100).floor}% complete"
}
We have what we need to define the individual pieces of our animation, but now we want to wrap it up in one parent animation, with the parent mainly delegating to the child animations, but still doing some extra work like setting up the initial state and looping the whole thing. This will be handy:
class Enumerator::Yielder
def run enum
enum.each { |v| self << v }
end
end
Now a single enumerator’s yielder can go off on a side quest to run another animation to completion before continuing on to the next stage of animation. Combined with the built-in Enumerator::+ that concatenates enumerators to run one after the other, finally we can rewrite our original animation:
args.state.animation ||= Enumerator.new { |yielder|
args.state.sprite_pos = 320
loop do
yielder.run(
ecount(0.5.seconds) +
eease(1.seconds, easing_func) { |t|
args.state.sprite_pos = t.lerp(320, 960)
} +
ecount(0.5.seconds) +
eease(1.seconds, easing_func) { |t|
args.state.sprite_pos = t.lerp(960, 320)
}
)
end
}
args.state.animation.next
This now reads very similarly to our original English description without
needing any comments! I haven’t even mentioned some of the bonus benefits yet.
Now that we can uniformly represent all of our animations as Enumerator
objects, we get fun new options like easily delaying frames (skip a call to
next
) to simulate lag, easily skipping frames (call next
twice) to speed up
animations after the fact, easily reset or replace animations while they are
running (reassign the variable that stores the animation), etc.
I’m still playing around with this pattern and exploring the possibilities, but I would love to see something like this added to DragonRuby’s open source module at some point. I may do it myself.
But in the meantime, you can accomplish a lot with just ecount
, eease
, and
Enumerator::Yielder::run
. All of the code in this blog post is licensed
CC0 so go forth and make stuff!