For animations, games, and scientific purposes.
Discrete event simulation is an important field for working on a multitude of common tasks such as animation. However, it is not needed to know any theoretical concept, this library provides a simple way of understanding what it is all about.
We will need this:
(require '[dvlopt.dsim :as dsim])
Everything revolves around transitions. A transition is a pure stepwise function
gradually modifying some arbitrary state presented in the form of a map.
Actually, transitions are part of the state itself and are located under a
specific key (ie. dvlopt.dsim/transition-key
). It is both common and desired
for transitions to mirror the structure of the data they act upon. Hence, they
are often organized in nested maps. Let us pretend we wish to animate asteroids
moving in 2D:
(def state
{dsim/transition-key {:asteroids {42 {:x ...
:y ...}}}
:asteroids {42 {:x 450
:y 1420}}})
This mirroring pattern is so common that, for instance, the path leading to an :x value would be called the "data-path" (ie. [:asteroids 42 :x]). A transition, once it is created, takes 3 arguments: the state it needs to probably modify, its data-path, and the current step. A step represents some discrete point in time. In a live animation, that would most likely the current millisecond.
;; Let us add a transition starting from step 0, lasting 2000 steps, and moving
;; asteroid 42 on its X axis from 200 pixels to 800.
(def state-2
(assoc-in state
(dsim/transition-path [:asteroids 42 :x])
(dsim/once 0
2000
(fn on-step [state data-path percent]
(assoc-in state
data-path
(dsim/scale 200
800
percent))))))
As described below, transitions are created by providing at least their first
step (ie. when do they begin), how many steps they last, and an on-step
function which takes 3 arguments just like the resulting transition. However,
the 3rd argument is not the current step but rather a percentage of completion
computed as (current-step - first-step) / n-steps. It makes things a lot easier
because the step itself does not really matter, we simply need to know how far
in the transition are we.
There exists 3 kinds of basic transitions:
once
, which lasts n-steps
and terminatesinfinite
, which endlessly repeats n-steps
repeating
, which repeats n-steps
n-times
When creating non-infinite transitions, an on-complete
function can be
provided. When a transition terminates, it automatically removes itself from the
state and calls its on-complete
if there is any. This function takes 4
arguments: the current state map, the data-path, the completion step and the
current step. When some steps are skipped or missed, the completion step and the
current step might not match, hence it is useful to provide both. This happens
in live animations when a frame is not drawn at the exact millisecond a
transition is supposed to complete. Those on-complete
functions are useful
when some action needs to be taken after completion, such as creating a new
transition.
Chaining transitions is so common that we can now introduce poly-transitions.
Those higher-order transitions are created by providing a sequence of functions
returning transitions. After each such "sub-transition", the poly-transition
take care of creating and adding the next "sub-transition" by calling the next
function in the sequence. 3 arguments are provided to those functions: the state
at that moment, the current step, and an on-complete
function whose purpose is
to - sort of recursively - repeat this process for the next "sub-transition".
;; While it simply moves on its X axis, we want asteroid 42 to move on its Y
;; axis as well. Starting at step 0, it will first go up from 500 pixels to 700
;; in 1000 steps. Then, right after that, it will go down from 700 pixels back
;; to 500 in 2000 steps.
;; `dvlopt.dsim/fn-once` helps us for creating a function returning a transition
;; as mentioned in the last paragraph.
(def state-3
(assoc-in state-2
(dsim/transition-path [:asteroids 42 :y])
(dsim/poly state-2
0
[(dsim/fn-once 1000
(fn on-step [state data-path percent]
(assoc-in state
data-path
(dsim/scale 500
700
percent))))
(dsim/fn-once 2000
(fn on-step [state data-path percent]
(assoc-in state
data-path
(dsim/scale 700
500
percent))))])))
Similarly to basic transitions, there exists 3 kinds of poly-transitions:
poly
, like once
, goes through its sequence only oncepoly-infinite
, like infinite
endlessly goes through its sequencepoly-repeating
, like repeating
, goes through its sequence
exactly n-times
Those 6 functions for creating transitions have helpers prefixed by fn-
(eg.
once
-> fn-once
) which can be used for creating poly-transitions just like
in the example above. It is trivial to create arbitrarily complex nested
poly-transitions.
Our asteroid is moving linearly. What if we want it to move non-linearly? For instance, going faster and faster on its X axis? This is simply done by mapping the percentage of completion, which is linear, to a non-linear progression. For instance, we could use the ol' quadratic function as such:
(fn on-step [state data-path percent]
(assoc-in state
data-path
(dsim/scale 200
800
(Math/pow percent
2))))
The library provides a series of functions for removing boilerplate. For
example, you have probably noticed we always use assoc-in
with the data-path
in our on-step
functions. Here is how we could use the library for doing
everything we have done so far at once:
;; First, we merge our transitions with the current state which is simply an
;; empty map at this point. Our `on-step` functions are very declarative, they
;; take care of mapping the percentage of completion to a pixel value, linearly
;; or not. Our transitions are also provided with a pre-existing `on-complete`
;; function which will entirely remove our asteroid from the state only when all
;; its transitions are done.
(def state
(dsim/merge-transitions
{}
{:asteroids {42 {:x (dsim/once 0
1000
(dsim/fn-mirror-percent (comp (dsim/fn-scale 200
800)
#(Math/pow %
2)))
dsim/remove-pre-data)
:y (dsim/poly {}
0
[(dsim/fn-once 1000
(dsim/fn-mirror-percent (dsim/fn-scale 500
700)))
(dsim/fn-once 2000
(dsim/fn-mirror-percent (dsim/fn-scale 700
500)))]
dsim/remove-pre-data)}}}))
Up to now, we have only created transitions. However, remember transitions are just functions, they will not do anything if there are not called. Since we are all setup, we can move our state through steps. Remember a step is any point in some arbitrary time. It could be a specific frame in an animation, or a specific millisecond in a live animation, a meaningful step in a scientific simulation, or anything.
This is the most basic way of taking a state and moving it to the step you want:
(def state-at-0
(dsim/move state
0))
All transitions starting at step 0 now have gone through their first step. In other words, our asteroid has begun moving. Often, there is no need for moving step by step by ourselves. We can use the following function for mapping a sequence of steps to a lazy sequence of [state' step]:
(def simple-simulation
(dsim/move-seq state
(range)))
Indeed, the sequence of steps we provide starts from 0 and goes to infinity. However, the sequence of [state' step] will not be infinite. It stops as soon as there are no more transitions because it make no sense to continue, at this point the state cannot evolve anymore. Hence, our sequence of states stops when our asteroid stops moving.
This is a bit too easy. In real use, such as in the context of a game or any
non-trivial animation, events happen and they modify the state. For instance, if
a gamer presses the "jump" button, its character must indeed jump in the game.
Such events must be associated with a step and be handled properly. In the
context of a game, our state could be in an atom. When the "jump" button is
pressed, we can swap!
this atom and add a transition - starting from the
current millisecond - to our character so that it jumps.
Nonetheless, when drawing an animation a posteriori or doing some scientific simulation, we often have the events in advance. It becomes tricky to take care at the same time of both our state moving through steps and events happening at some particular steps. Hence, here is what we can do using the library. Let us say we want to create a short film of moving asteroids:
;; Our sequence of events. 2 asteroids will be created and they will eventually
;; collide. Each step is a millisecond.
(def events
[{:type :create-asteroid
:number 0
:x 200
:y 250
::dsim/step 0}
{:type :create-asteroid
:number 1
:x 750
:y 600
::dsim/step 2000}
{:type :collision
:x 500
:y 500
::dsim/step 5000}])
;; In a non-trivial simulation, we often need to see where things are going.
;; Here, our asteroids will move towards a point of collision, we need to know
;; that in advance. We find the :collision event and extract that point.
(defn initial-state
[events]
(let [collision (first (filter (comp #(identical? %
:collision)
:type)
events))]
{:collision (select-keys collision
[::dsim/step
:x
:y])
:status :running}))
;; There are only 2 kinds of events: the creation of an asteroid and the final
;; collision. When an asteroid is created, a transition is setup so it moves
;; from its original coordinates as described in the event to the point of
;; collision we have extracted in our initial state. For clarity as well as
;; emphasizing the fact they last the same number of steps, the :x and :y values
;; are modeled within the same transition. It is also slightly more efficient.
(defn handle-event
[state event]
(condp identical?
(:type event)
:create-asteroid (let [collision (:collision state)
asteroid-step (::dsim/step event)
n-steps (- (::dsim/step collision)
asteroid-step)]
(dsim/merge-transitions
state
{:asteroids {(:number event) (dsim/once asteroid-step
n-steps
(let [scale-x (dsim/fn-scale (:x event)
(:x collision))
scale-y (dsim/fn-scale (:y event)
(:y collision))]
(dsim/fn-mirror-percent (fn percent->coords [percent]
{:x (scale-x percent)
:y (scale-y percent)}))))}}))
:collision {:status :bang}))
;; All right, now we can lazily compute our sequence of [state' step]. Our
;; events will handled when needed. Although we provide an infinite range of
;; steps, the sequence stops as soon as there are no more events and all
;; transitions finish, meaning the state cannot evolve anymore.
(def simulation
(dsim/move-events (initial-state events)
(range)
events
handle-event))
;; That's all. But what if we want to draw this animation as a series of files
;; using the Quil library? It easy provided we already have some `draw-state`
;; function somewhere. The only thing we have to do with our simulation is to
;; run it so that steps represent frames and not milliseconds. With a frame-rate
;; of 60 frames per second:
(doseq [[state'
step] (let [events' (map #(update %
::dsim/step
dsim/millis->n-steps
60)
events)]
(dsim/move-events (initial-state events')
(range)
events'
handle-event))
(draw-state state')
(q/save (format "frame-%016d.jpeg"
step)))
By being built as it is, this library offers an easy and efficient way or writing transitions while remaining particularily flexible if the user needs to handle some special case. In the end, it is just functions calling functions, nothing magic.
Copyright © 2019 Adam Helinski
Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close