module Easing

ちょと前にDXRuby Advent Calendar 2014の記事を書いたのだが、やっぱり突貫工事で記事を書くとちょっと辛いものがある。1日ぐらい寝かしてからアップすればよかったか。と言っても指摘事項はほんとに厳しいところを突かれていて、例えばティアリング防止なんか真面目に書き始めたら記事3日分とかになりそうな厄介な話だったりするので、色々はしょってあんな感じ。ティアリング防止やりたかったんだけどね。難しいんよね。ちなみにどんな話になるかというと、例えばバッファの扱いとflip/copyの違いとか、DirectXのスレッド制限とRubyのGVLに対するDXRubyの回避策としての内部マルチスレッド化とか、DirectXのPresent関数の仕様と挙動の話とか、DXRubyのFPS制御とか、V-Sync(垂直同期)とは何かとか。それらがどう絡み合って、結果、どういう理由で挫折したのか。書いたら誰かヒントくれるかもしれんなあ。
さておき、DXRuby Advent Calendar 2014といえば今日の記事がヤバい。wannabe53さんの「DXRubyをWineから使う」なんだけどもこれ、いま現在Wineを使えばMacとかlinuxとかで動いちゃうってことじゃん?ヤバいな。かなりヤバい。sdl2rいらない子?dxruby_sdlの弱点は描画まわり(拡縮回転)と衝突判定、シェーダだと思う。Ruby/SDL2を使えば描画まわりは改善できるとして、衝突判定はCで移植するしか無いが、シェーダはお手上げ。トランスレータが使えればもしかしたらOpenGLでいけるかも?みたいな?でもSDL2の描画と組み合わせるのは仕組み的にちょっと無理か。

っと、前置きが長くなってしまったが、今回は本編の記事もそれ以上に長い。jQueryのイージング機能をRubyで実装してみたよ、というネタである。

Easingモジュールの基本

まず、いまだ開発中ではあるが動いているので公開。gistにおいてある。ひどいタイトルだが、そんな感じ。バグがあったりしたら修正するし、際どいところの挙動は変わるかもしれない。
モノとしてはjQueryのAnimate関数でできることをRubyでやってみただけである。が、せっかくなので色んな使い方ができるように巧妙に細工してある。まず簡単なサンプルを出してみよう。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(100, 100, image)

s.animate({x:500, y:300}, 120)

Window.loop do
  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

起動すると、フォントで描画された絵が(100,100)の場所から(500,300)の場所に移動する。Spriteを継承したMySpriteにEasingモジュールをincludeして、そのanimateメソッドで移動先と移動時間を指定する。移動の処理はupdateメソッドで自動的に行われる。
イージングはselfのパラメータを現在の値から指定の値に、指定時間かけて変化させる機能である。xとyを変化させることでキャラを移動させることができる。第3引数は省略してあるので一定速度で移動しているが、ここに内蔵イージング関数名をシンボルで与えることでだんだん加速したり、減速したり、弾むような動きをしたり、といった効果が付けられる。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(100, 100, image)

s.animate({x:500, y:300}, 120, :out_bounce)

Window.loop do
  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

第3引数だけ追加してみた。弾むような動きをするようになっただろう。内蔵イージング関数に何があるかはEasingモジュールのソースを見るか、直接動かしてみれば画面に表示されるし、それで動きを見ることもできる。
イージングモジュールのanimateはselfに対して第1引数のシンボルの名称のメソッドを呼び出して値を取得し、=を付けて呼び出して設定する、という仕掛けになっている。なので、getterとsetterが対になって存在していれば、xやyだけじゃなく、他のどんなメソッドに対してでも使える。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(100, 100, image)

Window.loop do
  if Input.key_push?(K_Z)
    s.animate({angle:(s.angle+360)}, 60)
  end

  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

これはSprite#angleに対して使ってみた例で、Zを押すと1秒かけて1回転する。
座標の移動に関して言うと、イージングは距離に寄らず指定時間で値の変化を終えるので、距離が遠ければ速くなるし近ければ遅くなる。そのような動きを求められるのは例えばユーザインターフェイスのメニューのカーソル移動などだが、逆にアクションゲームではキャラのスピードが重要になるので、そのまま移動に使うのはちょっとよろしくない。指定時間を距離から算出するようにすればそういう用途に使えないことも無いだろう。SpriteにEasingを適用すればイージングで移動や回転や拡大率の変化中でも衝突判定はきちんと実行できる。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(100, 100, image)
ms = Sprite.new
ms.collision = [0, 0]

Window.loop do
  if Input.key_push?(K_Z)
    s.animate({x:400, y:300, scale_x:4, scale_y:4}, 120, :out_elastic)
  end
  if Input.key_push?(K_X)
    s.animate({x:100, y:100, scale_x:1, scale_y:1}, 120, :out_elastic)
  end
  ms.x, ms.y = Input.mouse_pos_x, Input.mouse_pos_y
  Window.draw_font(0, 0, "hit!", Font.default) if ms === s
  
  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

これはまあ、Imageサイズの矩形で判定しているので大雑把だが、判定できていることはわかるだろう。

Easingモジュールの各種機能と応用

loop機能

コードそのものはわりと短いのだが、単純なイージングだけではなく、もうちょい機能を用意してある。例えばanimateメソッドはオプションとしてキーワード引数を持っていて、loop:trueとするとイージングが完了してもループして延々と繰り返す。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(100, 100, image)
s.angle = 0
s.animate({angle:360}, 60, loop:true)

Window.loop do
  if Input.mouse_push?(M_LBUTTON)
    mx, my = Input.mouse_pos_x-48, Input.mouse_pos_y-48
    s.animate({x:mx, y:my}, 120, :out_quint)
  end
  s.stop_animate([:x, :y]) if Input.mouse_push?(M_RBUTTON)

  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

動かしてみてもらえるとわかるが、回転が止まらない。マウスクリックで移動するがこれもイージング機能でやっている。この例から、イージングの各種パラメータが個別に処理されていることがわかるだろう。また、stop_animateメソッドでパラメータを指定して中断することができる。これもそれぞれのパラメータが完全に別になっているからこそできる技だ。stop_animateは引数を渡さないとすべてのイージングを停止する。

自作getter/setterとの合わせ技

これはちょっと応用になるが、このloop機能と自作のgetter/setterを使って、以下のように画像のアニメーションをやらせることもできる。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite
  include Easing
  attr_accessor :animation_number

  @@animation_images = "1234".chars.map {|i| Image.new(96, 96).draw_font(0, 0, i.to_s, Font.new(96))}

  def update
    super
    self.image = @@animation_images[@animation_number]
  end
end

s = MySprite.new(100, 100)
s.animation_number = 0
s.animate({animation_number:4}, 120, loop:true)

Window.loop do
  if Input.mouse_push?(M_LBUTTON)
    mx, my = Input.mouse_pos_x-48, Input.mouse_pos_y-48
    s.animate({x:mx, y:my}, 120, :out_quint)
  end
  s.stop_animate([:x, :y]) if Input.mouse_push?(M_RBUTTON)

  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

もちろん他のイージングとは別に処理しているので、引数無しのstop_animateしたり、animation_numberのイージングを上書きしない限りはアニメーションし続ける。

イージング終了時呼び出し

animateメソッドにはブロックを渡すことができる。このブロックは内部でProcオブジェクトに変換して保持し、イージング処理が完了したときに呼ばれる。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(100, 100, image)

flg = false
Window.loop do
  if Input.key_push?(K_Z)
    s.angle = 0
    s.animate({angle:360}, 60) do 
      flg = true
    end
  end

  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
  break if flg
end

このコードはZを押すと回転し、回転が終わるとプログラムが終わる。イージングが移動処理だったりした場合、移動が終わったときに何かをする、ということは多いと思うので、これをブロックで記述できるようにした。Procオブジェクトに変換して実行そのものはupdate内で行うので、breakやreturnをすると例外になる。

終了時呼び出しでのanimate呼び出しによるイージング連結技

終了処理ブロックの中からanimateを呼ぶことでイージング処理の連結ができる。ネストしていけばいくらでも連結することができるが、無限に繰り返す処理はその書き方では不可能である。RubyのProcは外側の変数を参照することでクロージャとして機能するので、これを使って相互参照するProcを作れば相互に繰り返し呼び出し続ける無限連結が可能となる。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(100, 100, image)

p2 = nil # これ重要
p1 = Proc.new do
  s.animate({x:500, y:300}, 30, :out_quint) do 
    p2.call
  end
end
p2 = Proc.new do
  s.animate({x:100, y:100}, 30, :out_quint) do 
    p1.call
  end
end  
p1.call

Window.loop do
  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

これを動かすと移動指定のanimateを交互に呼ぶのでいったりきたりし続ける。無論、こんな書き方しなくてももうちょっとわかりやすいラッパメソッドを作ればいいのだろうが、とりあえず基本機能しか実装してないので基本機能の応用サンプルとして。

イージング関数の自作

イージング関数というのは、変化の進捗度合いを0.0〜1.0で受け取り、何かしら補正をして返す関数である。返る値は場合により0.0〜1.0に収まらないこともあるが、移動のことを考えると最終的に1.0に収束してくれないと困る。例としてlinerの処理を見てみよう。

    :liner => ->x{x},

いろいろ面倒だったのでlambda記法で書いてあるが、xを受け取ってxを返すだけである。進捗度合いは線形に増加するので、返す値もそのままで線形に変化させられる。これだけではさすがにアレなので、もうひとつ。

    :in_quad => ->x{x**2},

受け取った値を2乗している。受け取る値は0.0〜1.0なので、0のときは0、1のときは1となり、0.1のときは0.01、0.9のときは0.81である。小さいときは変化が少なく、大きいときに変化が大きくなり、加速する効果が得られる。加速するといっても最初が遅く最後が速いだけで、結果としてlinerと同じだけの時間をかけて変化する。
さて、Easingモジュールはこのイージング関数の自作に対応している。と言っても面白い効果はたいがいjQueryで作られてしまっているので、普通じゃないものを作ってみようか。

require 'dxruby'
require_relative 'easing'
class MySprite < Sprite;include Easing;end

image = Image.new(96, 96).draw_font(0, 0, "\u004d", Font.new(96, "Wingdings"))
s = MySprite.new(300, 300, image)

parabola_easing_proc = ->x{1.0 - (((x - 0.5) * 2) ** 2)}

Window.loop do
  s.x += Input.x * 5
  if Input.key_push?(K_Z) and s.y == 300
    s.animate({y:0}, 60, parabola_easing_proc)
  end
  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

parabola_easing_procというのが自作イージング関数である。animateメソッドの第3引数にProcオブジェクトを渡すと、それがイージング関数として使われる。この例ではちょっとややこしい計算式だが、受け取る値が0.0〜1.0なのに対して、0.0の時は0.0を返し、だんだん増えていって0.5の時に1.0、また減って1.0の時に0.0を返す。単純なy=a(x-p)**2という放物線の方程式である。Zを押すとジャンプしているように見えるだろう。
この例の特殊なところはイージング関数が返す値が最終的に0.0になるところで、これは普通のイージング関数ではない。移動に使おうと思ったら行って戻ってきてしまって移動できない。なので用途が限定されるわけだが、だからこその自作イージング関数というわけだ。

自作のSetter呼び出し関数

Easingモジュールでは指定されたパラメータ名に=付けてSetterを呼び出しているのだが、呼び出して渡す値は「easing_function(x) * (to - from) + from」という計算式で算出される。通常の用途であればそれで事足りるのだが、通常じゃない用途にもイージングを使いたい場合、単純な計算をされては困ることがある。例えば色だ。
Setter/Getterさえあれば何でもイージングできるが、デフォルトの計算式ではFloatを受け取ってくれるものじゃないといけない。DXRubyにおける色は配列なので、これではイージングできないわけだ。こういう場合にはキーワード引数のset_procを使う。

require 'dxruby'
require_relative 'easing'
class MyClass 
  prepend Easing # updateを持つクラスを継承していないのでprependする
  attr_accessor :color

  def initialize(c)
    @color = c
  end

  def update;end

  def draw
    Window.draw_box_fill(100, 100, 200, 200, @color)
  end
end

s = MyClass.new([0,0,255])
mysetproc = ->x, param{
  Array.new(3){|i| x * (param.to[i]-param.from[i]) + param.from[i]}
}

Window.loop do
  if Input.key_push?(K_Z)
    s.animate({color:[255, 0, 0]}, 60, :in_quint, set_proc: mysetproc)
  end
  if Input.key_push?(K_X)
    s.animate({color:[0, 0, 255]}, 60, set_proc: mysetproc)
  end

  s.update
  s.draw
  break if Input.key_push?(K_ESCAPE)
end

このコードのmysetprocというのがset_procに渡されるProcである。引数はイージング関数からの戻り値であるxと、内部のEasingParameter構造体オブジェクトとなっていて、この構造体にはfromとして最初にGetterを呼び出して取得したオブジェクトと、toとしてanimateのtoに渡された値が格納されているので、これを使って処理する。mysetprocが返した値がSetterの引数となって、セットされる。
サンプルコードではfromにもtoにも3要素の色配列が入っていることになっているので、要素ごとに計算して色配列を生成して返している。また、Zを押したときのanimateの引数に:in_quintが設定されていることからわかるように、mysetprocが受け取る値はイージング関数の影響を受ける。これらの組み合わせで様々なことができる柔軟さを得ている。
ところで今回のコードではSpriteを使っていない。EasingモジュールはSetterとGetterの存在のみが必要な用件であり、別にSpriteに依存はしていないのである。ついでに言うと、initializeをオーバーライドしていないので初期化する必要がなく、Object#extendで機能を後付けして使うことも可能だ。

おしまい

一気に書き上げたので消化しきれなかもしれないが、Easingモジュールはこのように非常に柔軟な作りになっていて、簡単なことはデフォルト設定と内蔵イージング関数で簡単に、例外的なことはProcオブジェクトを渡して例外的に処理することができる。ちょっと考えただけでこんだけの応用ができるので、まだまだ色んなことに使えそうである。
また何かひらめいたら記事にするかもしれない。