🎬

複雑なアニメーションをプログラムする 〜Reanimate入門〜

2020/12/31に公開

Reanimateはアニメーションを作成するためのライブラリです。

ReanimateはHaskellのライブラリとして実装されているのでプログラムによってアニメーションを記述することができます。ライブラリに実装されている機能も多く、ドキュメントも豊富ですし、オンラインのPlaygroundまで用意されていてかなり完成度の高いライブラリになっています。さらにLaTeXや物理エンジン(Chipmonk 2D), POV-Ray, Blenderなど外部ツールとの連携もサポートされています。アニメーションの各フレームはSVGで書き出されるようになっており、幾何学的な図形やSVGフォントを使った文字などから構成されたアニメーションを作るのが得意です。作ったアニメーションは最終的にMP4, GIF, WebMに出力することができます(中間生成物である各フレームのSVGを取り出すことも可能です)。

公式サイトにExampleが多く載っているので、まずはReanimateを使ってどの様なアニメーションが作れるのか確認してみると良いでしょう。

https://reanimate.github.io/

この記事ではReanimateを使ってアニメーションを作るために必要な基本的な概念を解説していきたいと思います。

Reanimate 入門

Reanimateを使ったアプリケーションは以下のreanimate関数を使ってmainを実装するのがゴールになります。

reanimate :: Animation -> IO ()

この関数によって実装されるアプリケーションの使い方は後で見るとして、まずは引数となっているAnimation型について見ていきましょう。

Animation

これは言わずもがなアニメーションを表す型です。

mkAnimation :: Duration -> (Time -> SVG) -> Animation

という関数から作ることができ、全体の長さと各時間におけるSVGを指定するとアニメーションになることが分かります。

DurationTimeDoubleのエイリアスです。

type Duration = Double
type Time = Double

SVG

SVGはその名の通りSVGを表す型です。以下のような関数から基本的な図形を作成することができます。

-- | 円
mkCircle :: Double -> SVG

-- | 楕円
mkEllipse :: Double -> Double -> SVG

-- | 長方形
mkRect :: Double -> Double -> SVG

-- | 線分
mkLine :: (Double, Double) -> (Double, Double) -> SVG

-- | 折れ線
mkLinePath :: [(Double, Double)] -> SVG

-- | パス
mkPath :: [PathCommand] -> SVG

-- | 色名から背景を塗りつぶすSVGを作成する
mkBackground :: String -> SVG

-- | 複数のSVGを組み合わせて一つのSVGにする
mkGroup :: [SVG] -> SVG

実はHaddock上では上記関数の返り値の型はTreeになっているのですが、SVGTreeの型エイリアスなので問題ありません。

type SVG = Tree

上述のような関数で単純に生成したSVGは画面の真ん中が中心になるように配置されます。SVGの位置などのプロパティはSVG -> SVG型の関数によって変更することができます。

-- | SVGを x, y 方向に移動させる
translate :: Double -> Double -> SVG -> SVG

-- | SVGを回転させる
rotate :: Double -> SVG -> SVG

-- | SVGを拡大縮小する
scale :: Double -> SVG -> SVG

-- | 線の色を変える
withStrokeColor :: String -> SVG -> SVG

-- | 塗りつぶしの色を変える
withFillColor :: String -> SVG -> SVG

これだけで色んなアニメーションが作れそうですが、実はアニメーションを作るなら後述するObject型をTweenを使って操作するほうが簡単な場合もあります。

簡単なアニメーション

まだまだ便利な機能はありますが、ここまでの概念で一度簡単なアニメーションを作成してみましょう。

drawBox :: Animation
drawBox = mkAnimation 2 $ \t ->
  partialSvg t $ pathify $
  mkRect (screenWidth/2) (screenHeight/2)

これは四角形を描くアニメーションになります。screenWidth, screenHeightは画面の大きさを取得する関数、pathifymkCircleなどで作られたSVGをパスによる表現に変換する関数で、partialSvgはパスを途中まで描いたSVGに変換する関数です。それぞれの型は以下のようになっています。

screenWidth :: Fractional a => a
screenHeight :: Fractional a => a
pathify :: SVG -> SVG
partialSvg :: Double -> SVG -> SVG

drawBoxが記述しているアニメーションを見てみましょう。

イメージ通りですね👏

同様に円を描くdrawCircleを実装することもできます。

drawCircle :: Animation
drawCircle = mkAnimation 2 $ \t ->
  partialSvg t $ pathify $
  mkCircle (screenHeight/3)

作成したアニメーションは以下のようなAnimation -> Animation型の関数を使って変化させることができます。

-- | アニメーションを反転させる
reverseA :: Animation -> Animation

-- | アニメーションを再生してから反転させる
playThenReverseA :: Animation -> Animation

-- | アニメーションが始まる前に動かない時間を作る
pauseAtBeginning :: Duration -> Animation -> Animation

-- | アニメーションが終わった後に動かない時間を作る
pauseAtEnd :: Duration -> Animation -> Animation

-- | 各フレームのSVGを一様に変換する
mapA :: (SVG -> SVG) -> Animation -> Animation

例えば playThenReverseA を使えば drawBox は以下のようになります。

このアニメーションはdrawBoxreverseA drawBoxを順次つなげたような形になっていますよね。次はアニメーションを合成してより複雑なアニメーションを作る方法を見てみましょう。

-- | 1つ目のアニメーションを再生した後に2つ目のアニメーションを再生する
seqA :: Animation -> Animation -> Animation

-- | 1つ目のアニメーションを再生した後に、その結果を残しつつ2つ目のアニメーションを再生する
andThen :: Animation -> Animation -> Animation

-- | 2つのアニメーションを同時に再生する
parA :: Animation -> Animation -> Animation

-- |2つのアニメーションを同時に再生しつつ、短い方のアニメーションをループする
parLoopA :: Animation -> Animation -> Animation

-- |2つのアニメーションを同時に再生しつつ、短い方のアニメーションが終わったらそこで終了する
parDropA :: Animation -> Animation -> Animation

seqAandThen の違いが分かりにくいかと思うので実際に見てみましょう。

まずは drawBox `seqA` drawCircle です。

次に drawBox `andThen` drawCircle です。

最初に描かれた四角形が残り続けていることが分かります。

ところでこのように順番に再生されるアニメーションを記述するのであれば、モナドを使った手続き型のインターフェースがあればより直感的に書くことができそうです。ReanimateではそのためにScene s aというモナドのインスタンスとなっている型が用意されています。

Scene s a

Scene s a は手続き的に扱えるアニメーション(シーン)を表す型です。以下のplay関数を使ってAnimation型から作成することができます。

play :: Animation -> Scene s ()

反対にScene s aからAnimation型を作ることも可能です。

scene :: (forall s. Scene s a) -> Animation

Scene s a には Var s a という読み書き可能な変数の機能があり s が存在型となっていることで ST s a 同様、変数の値を外から取り出せないようになっています)

早速これらを使ってdrawBoxdrawCircleを順番に実行するアニメーションを作成してみましょう。

scene do
  play drawBox
  play drawCircle

(doの前の$を省略するにはBlockArguments拡張を有効にする必要があります)

bindによりアニメーションはシーケンシャルに再生されますが fork :: Scene s a -> Scene s a を使うことで並列に再生することも可能です。

これはparAを使った時と同じ挙動になります。

Sprite s a

ここまではAnimation型でもできることでしたが、Scene s aの機能としてアニメーションの対象をSprite sとして切り出し直接操作することができます。

newSpriteA :: Animation -> Scene s (Sprite s)

これらの関数は Sprite s を作成すると同時にシーンの中でアニメーションを再生することも行います。

Sprite s には以下のような操作が用意されています。

-- | Sprite を破壊して画面から消す
destroySprite :: Sprite s -> Scene s ()

-- | Sprite に対して時間に応じたSVGの変換を適用する
spriteTween :: Sprite s -> Duration -> (Double -> SVG -> SVG) -> Scene s ()

-- | Sprite にエフェクトを適用する
spriteE :: Sprite s -> Effect -> Scene s ()

まずは destroySprite を試してみましょう。

scene do
  s <- fork $ newSpriteA drawBox
  fork $ wait 1 >> destroySprite s
  play drawCircle

まず fork を使って四角形を描き始めた後に、再び fork で1秒後に四角形を描いているSpriteを消す処理を走らせます。最後に円を描き始めることで、2秒かけて円を描いている途中の1秒目で四角形が消えるというアニメーションを作ることができました(ここでwaitScene s aの中で指定された秒数だけ待つ関数です)。

次にエフェクトを使ったアニメーションを見てみましょう。

scene do
  s <- fork $ newSpriteA drawCircle
  spriteE s $ overBeginning 1 fadeInE
  spriteE s $ overEnding 0.5 fadeOutE

円を描く drawCircle アニメーションが、開始一秒でフェードイン overBeginning 1 fadeInE し、終了する0.5秒でフェードアウト overEnding 0.5 fadeOutE しているのが分かります。Effectはこれだけでなく豊富な種類が用意されていますが、ここではObject s aを使ったモーションの説明に移りたいので気になる人は以下のHaddockを参考にしてください。

https://hackage.haskell.org/package/reanimate-1.1.2.1/docs/Reanimate-Effect.html

Object s a

Sprite s はアニメーションを伴う対象を Scene s a の中で扱うための型でしたが、Object s aはSVGのようなオブジェクトを Scene s a の中で扱うための型です。

oNew :: Renderable a => a -> Scene s (Object s a)
oShow :: Object s a -> Scene s ()

SVGRenderable のインスタンスになっているので oNew を使って Object s a を作ることができます。oNew しただけでは Object s a は表示されず明示的に oShow を呼ぶ必要があります。

Object s a は以下のような関数を使って操作することができます。

-- | オブジェクトの持つプロパティを変更する
oModifyS :: Object s a -> State (ObjectData a) b -> Scene s ()

-- | 時間をかけてオブジェクトの持つプロパティを変更する
oTweenS :: Object s a -> Duration -> (Double -> State (ObjectData a) b) -> Scene s ()

oTweenS を使えば以下のように終了時の座標を指定するだけで Object s a を動かすことができます。

scene do
  obj <- oNew $ mkCircle 2
  oShow obj
  oTweenS obj 2 (\t -> oRightX %= \origin -> fromToS origin screenRight t)

oRightX はレンズになっていて %= を使うことで State の中でその値だけ書き換えるという操作を行っています。

oRightX :: Lens' (ObjectData a) Double
(%=) :: MonadState s m => ASetter s s a b -> (a -> b) -> m ()

つまりオブジェクトの右端のx座標oRightXを元の位置originから画面の右端screenRightまで刻みながらfromToS変化させるという事を行っているのです。

ところで今回はオブジェクトの右端のx座標を変化させましたがObject s aの座標はどのように表現されているのでしょうか?もし左端を変化させたいと思った場合、オブジェクトの幅を考慮して右端の座標が変化する量を計算する必要があるのでしょうか?こういう内部表現は描画エンジン毎に異なってくるので普通は注意する必要がありますが、実は全く気にする必要はないのです。左端を変化させたければoLeftXというレンズが用意されているのでそれを使えば思ったとおりに動くでしょう。つまりオブジェクト位置の内部表現がどうなっていたとしても操作したい性質を表すレンズさえ用意されていれば、あたかもその性質を直接操作しているようにプログラムを書くことができるのです。これはレンズの非常に強力で便利な性質だと思います。

例として大きさの違う3つのオブジェクトを左揃えにすることを考えます。通常であれば原点の位置からそれぞれの幅を考慮して左に移動する距離をプログラマーが計算する必要がありますが。レンズを使えば"左端を揃えるだけ"で実装できてしまうのです。

scene do
  obj1 <- oNew $ translate 0 0    $ mkCircle 2.34
  obj2 <- oNew $ translate 0 1    $ mkRect 1.34 1.61
  obj3 <- oNew $ translate 0 (-1) $ mkCircle 0.45

  oShow obj1
  oShow obj2
  oShow obj3

  fork $ oTweenS obj1 2 (\t -> oLeftX %= \origin -> fromToS origin (-2) t)
  fork $ oTweenS obj2 2 (\t -> oLeftX %= \origin -> fromToS origin (-2) t)
  oTweenS obj3 2 (\t -> oLeftX %= \origin -> fromToS origin (-2) t)
  wait 1

大きさの違う3つのオブジェクトを想定通り左揃えにできていると思います。大きくて揃える左端が外側になっているオブジェクトは正しく右に移動していますね。

oRightX, oLeftX 以外にもObject s aに対して多くのレンズが用意されています。

-- | x軸方向の移動
oTranslateX :: Lens' (ObjectData a) Double

-- | y軸方向の移動
oTranslateY :: Lens' (ObjectData a) Double

-- | 上端のy座標
oTopY :: Lens' (ObjectData a) DoubleSource

-- | 下端のy座標
oBottomY :: Lens' (ObjectData a) DoubleSource

-- | 左端のx座標
oLeftX :: Lens' (ObjectData a) DoubleSource

-- | 右端のx座標
oRightX :: Lens' (ObjectData a) DoubleSource

-- | 中心のx座標
oCenterX :: Lens' (ObjectData a) DoubleSource

-- | 中心のy座標
oCenterY :: Lens' (ObjectData a) DoubleSource

-- | 上方向のマージン
oMarginTop :: Lens' (ObjectData a) DoubleSource

-- | 右方向のマージン
oMarginRight :: Lens' (ObjectData a) DoubleSource

-- | 下方向のマージン
oMarginBottom :: Lens' (ObjectData a) DoubleSource

-- | 左方向のマージン
oMarginLeft :: Lens' (ObjectData a) Double

-- | 透明度
oOpacity :: Lens' (ObjectData a) Double

-- | 拡大縮小倍率
oScale :: Lens' (ObjectData a) DoubleSource

reanimate関数の使い方

Animation型を作った後は reanimate 関数に適用しmainを実装します。

main :: IO ()
main = reanimate animation

このようにすればアプリケーションに引数を与えて実行することで動作ファイルを作成したりできるようになっています。

$ stack run -- --help
Usage: reanimate [COMMAND | [-v|--verbose] [--ghc PATH]
                            [-G|--ghc-opt ARG] [--self PATH]]
  This program contains an animation which can either be viewed in a web-browser
  or rendered to disk.

Available options:
  --ghc PATH               Path to GHC binary
  -G,--ghc-opt ARG         Additional option to pass to ghc
  --self PATH              Source file used for live-reloading
  -h,--help                Show this help text

Available commands:
  check                    Run a system's diagnostic and report any missing
                           external dependencies.
  view                     Play animation in browser window.
  render                   Render animation to file.
  raw                      Output raw SVGs for animation at 60 fps. Used
                           internally by viewer.

mp4ファイルを作成するには以下のようにします。

$ stack run render
Animation options:
  fps:    60
  width:  2560
  height: 1440
  fmt:    mp4
  target: output.mp4
  raster: RasterRSvg
Starting render of animation: 2.0
Frames generated: 120/120, time spent: 0s
Frames rastered: 120/120, time spent: 9s
Frames rendered: 120/120, time spent: 4s

mp4を作成するには ffmpeg が必要です。

その他の機能にも必要なツールが入ってるかどうかは以下のように確認することができます。

$ stack run check
reanimate checks:
  Has ffmpeg:                        4.3.1
  Has ffmpeg(rsvg):                  no
  Has dvisvgm:                       /Library/TeX/texbin/dvisvgm
  Has povray:                        /usr/local/bin/povray
  Has blender:                       no
  Has rsvg-convert:                  2.50.2
  Has inkscape:                      no
  Has imagemagick:                   no
  Has LaTeX:                         /Library/TeX/texbin/latex
  Has LaTeX package 'babel':         OK
  Has LaTeX package 'preview':       OK
  Has LaTeX package 'amsmath':       OK
  Has XeLaTeX:                       /Library/TeX/texbin/xelatex
  Has XeLaTeX package 'ctex':        OK

入っていないツールがあるからといって必ずしもアプリケーションを実行できないわけでは有りません。必要なものがあれば大丈夫です。

mp4ではなくGIFやWebMを書き出す時はフォーマットを指定します。

$ stack run render -- --format gif
Animation options:
  fps:    25
  width:  320
  height: 180
  fmt:    gif
  target: output.gif
  raster: RasterRSvg
Starting render of animation: 2.0
Frames generated: 50/50, time spent: 0s
Frames rastered: 50/50, time spent: 2s
Frames rendered: 50/50, time spent: 1s

これでGIFファイルが作成されます。

reanimateにはファイルを生成しなくてもアニメーションをブラウザで確認できる機能があります。これは.hsファイルを直接実行しないと使えないので例えば以下のようにしましょう。

$ stack runghc app/Main.hs

おわりに

reanimateは機能が豊富なのでここで紹介しきれなかったものはたくさんあります。例えばLaTexと連携すれば数式や英文に対して細やかなアニメーションを作成することが可能です。公式サイトにチュートリアルがあるので見てみてください。

https://reanimate.readthedocs.io/en/latest/tut_equation/

他にもChipmonk 2DやBlenderとの連携などまだまだ機能はありますが、入門記事ということでここで筆を置きたいと思います。最後にこの記事の冒頭に貼ったタイトルのアニメーションのコードを参考までに貼り付けておきます。

scene do
 s <- fork $ newSpriteA $ staticFrame 2 titleSvg
 spriteE s $ overBeginning 0.5 fadeInE
 spriteE s $ overEnding 0.4 fadeOutE
 spriteTween s 0.4 (flip const)
 spriteTween s 0.1 (\t -> scale (1+(0.1*t)))
 spriteTween s 1.1 (flip const)
 spriteTween s 0.4 (\t -> scale (1+(5*t)))
 where
   titleSvg = mkGroup
       [ scale 0.5 $ translate 0 3    $ mkText "複雑なアニメーションを"
       , scale 0.5 $ translate 0 0    $ mkText "プログラムする"
       , scale 0.5 $ translate 0 (-3) $ mkText "〜Reanimate入門〜"
       ]

\読んでいただきありがとうございました!/
この記事が面白かったら いいね♡ をいただけると嬉しいです☺️
バッジを贈っていただければ次の記事を書くため励みになります🙌

Discussion