🎨

Ruby/SDL2 ことはじめ

2022/04/25に公開約14,000字

はじめに

Ruby/SDL は更新されないものと諦めていたら Ruby/SDL2 が公開されていたので使ってみる。

セットアップ

とりあえず ~/src/ruby-sdl2-playground に作る。

#!/bin/sh
cd ~/src
rm -fr ruby-sdl2-playground
mkdir -p ruby-sdl2-playground
cd ruby-sdl2-playground
bundle init
bundle add ruby-sdl2 --require sdl2 --optimistic
bundle exec ruby -r sdl2 -e "p SDL2"

cat <<'EOF' > main.rb
require "bundler/setup"
Bundler.require(:default)

SDL2.init(SDL2::INIT_EVERYTHING)
pos = SDL2::Window::POS_CENTERED
window = SDL2::Window.create("(title)", pos, pos, 640, 480, 0)
flags = 0
flags |= SDL2::Renderer::Flags::PRESENTVSYNC
renderer = window.create_renderer(-1, flags)

120.times do
  SDL2::Event.poll
  renderer.present
end
EOF

bundle exec ruby main.rb
  • SDL2::Renderer::Flags::PRESENTVSYNC を指定すると垂直同期する

上のシェルスクリプトを流せば次のように Window を2秒間表示する。

window

本当に2秒?

Window は SDL2::Window.create のタイミングで起動しているわけじゃないみたい。
まず SDL2::Event.poll を呼んで溜まったイベントを捨て続けないといくら待っても起動しない。
で、次のイベントを順に受け流し、

  1. SHOWN
  2. EXPOSED
  3. FOCUS_GAINED

FOCUS_GAINED のタイミングでやっと表示される。
環境によって違うのかもしれないけど、私のところでは7ループ目に表示できた。
なので Window を2秒間表示するというなら次のように FOCUS_GAINED を待ってからにしないといけない。

catch :break do
  loop do
    while ev = SDL2::Event.poll
      case ev
      when SDL2::Event::Window
        case ev.event
        when SDL2::Event::Window::FOCUS_GAINED
          throw :break
        end
      end
    end
  end
end

sleep(2)

何か動かしてみる

require "bundler/setup"
Bundler.require(:default)

SDL2.init(SDL2::INIT_EVERYTHING)
pos = SDL2::Window::POS_CENTERED
window = SDL2::Window.create("(title)", pos, pos, 640, 480, 0)
flags = 0
flags |= SDL2::Renderer::Flags::ACCELERATED
flags |= SDL2::Renderer::Flags::PRESENTVSYNC
renderer = window.create_renderer(-1, flags)

include Math

frame_counter = 0
loop do
  while ev = SDL2::Event.poll
    case ev
    when SDL2::Event::Quit
      exit
    when SDL2::Event::KeyDown
      case ev.scancode
      when SDL2::Key::Scan::ESCAPE
        exit
      when SDL2::Key::Scan::Q
        exit
      end
    end
  end

  renderer.draw_blend_mode = SDL2::BlendMode::BLEND
  renderer.draw_color = [0, 0, 64, 28]
  renderer.fill_rect(SDL2::Rect.new(0, 0, *window.size))

  renderer.draw_blend_mode = SDL2::BlendMode::NONE
  renderer.draw_color = [255, 255, 255]

  r = 64
  w, h = window.size
  x = w / 2 + cos(PI * frame_counter * 0.02 * 0.7) * w * 0.4
  y = h / 2 + sin(PI * frame_counter * 0.02 * 0.8) * h * 0.4
  renderer.fill_rect(SDL2::Rect.new(x - r, y - r, r * 2, r * 2))

  renderer.present
  frame_counter += 1
end
  • SDL2::Renderer::Flags::ACCELERATED はなんとなく強そうなので入れてみた

lissajous
GIFの仕様でカクカクしてるけど実際はぬるぬる

ミニフレームワーク化

リポジトリに含まれるサンプルは上のようにベタ書きが多い。
サンプルとしてはベタ書きの方が一直線に処理が追えてわかりやすいけど、そのままコードを書き足していくと何がなんだかわからなくなる。
それに初期化処理やメインループを特定のキーで抜ける処理を毎回書きたくない。
なのでテンプレートメソッドパターンで必要なところだけ書くようにする。

require "bundler/setup"
Bundler.require(:default)

class Base
  include Math

  class << self
    def run(*args)
      new(*args).run
    end
  end

  def run
    setup
    loop do
      event_loop
      update
      before_view
      view
      after_view
    end
  end

  private

  def setup
    SDL2.init(SDL2::INIT_EVERYTHING)
  end

  def update
  end

  def view
  end

  def before_view
  end

  def after_view
  end

  def event_loop
    while ev = SDL2::Event.poll
      event_handle(ev)
    end
  end

  def event_handle(ev)
    case ev
    when SDL2::Event::Quit
      exit
    when SDL2::Event::KeyDown
      case ev.scancode
      when SDL2::Key::Scan::ESCAPE
        exit
      when SDL2::Key::Scan::Q
        exit
      end
    end
  end
end

# Base.run

──として呼び出し順序と最低限必要な処理だけ書いとく。
抽象クラスだけど Window が出ないアプリとして実行できる。

Window の追加

module WindowMethods
  attr_accessor :window
  attr_accessor :renderer
  attr_accessor :frame_counter

  def setup
    super

    flags = 0
    # flags |= SDL2::Window::Flags::FULLSCREEN
    # flags |= SDL2::Window::Flags::FULLSCREEN_DESKTOP
    pos = SDL2::Window::POS_CENTERED
    @window = SDL2::Window.create("(Title)", pos, pos, 640, 480, flags)

    flags = 0
    flags |= SDL2::Renderer::Flags::ACCELERATED
    flags |= SDL2::Renderer::Flags::PRESENTVSYNC
    @renderer = @window.create_renderer(-1, flags)

    @frame_counter = 0
  end

  def before_view
    super

    renderer.draw_blend_mode = SDL2::BlendMode::BLEND
    renderer.draw_color = [0, 0, 64, 28]
    renderer.fill_rect(SDL2::Rect.new(0, 0, *@window.size))

    renderer.draw_blend_mode = SDL2::BlendMode::NONE
    renderer.draw_color = [255, 255, 255]
  end

  def after_view
    super

    @frame_counter += 1
    renderer.present
  end
end

Base.prepend WindowMethods

# Base.run

実行して Window が出ればOK

マジックナンバーが目立つけどほっとく。
抽象化したり定数化したくなってくるけど逆にやらない我慢が大切で、やるメリットがめちゃくちゃある場合にやっとやるかやらないかぐらいでよい。

完成

class App < Base
  def view
    super

    r = 64
    w, h = window.size
    x = w / 2 + cos(PI * frame_counter * 0.02 * 0.7) * w * 0.4
    y = h / 2 + sin(PI * frame_counter * 0.02 * 0.8) * h * 0.4
    renderer.fill_rect(SDL2::Rect.new(x - r, y - r, r * 2, r * 2))
  end

  run
end

ミニフレームワークでコードがとてもシンプルになった。
ここだけ書きたかったんですわ。

ミニフレームワークの拡張

秒間フレーム数を知りたい

module FpsMethods
  attr_reader :fps

  def setup
    super

    @fps = 60
    @fps_counter = 0
    @old_ticks = SDL2.get_ticks
  end

  def update
    super

    @fps_counter += 1
    v = SDL2.get_ticks
    t = v - @old_ticks
    if t >= 1000
      @fps = @fps_counter
      @old_ticks = v
      @fps_counter = 0
      p fps
    end
  end
end

Base.prepend FpsMethods

# Base.run

クラス化して処理を分けたことで上のように元のコードを変更せずに機能を追加できた。
なので例えばジョイスティックの状態を読み取って扱いやすい形にしておくなどもこのようにモジュール化して追加すればよい。

ぱっと見、ミニフレームワーク内によくわからないインスタンス変数がばらまかれたのがいまいちなのでクラス化した方がよい気がするけどそれはいったん置いとく。

テキストを表示したい

上で求めた秒間フレーム数を画面上に表示させる。

require "pathname"

module FontMethods
  def setup
    super

    font_file = "~/Library/Fonts/Ricty-Regular.ttf"
    font_size = 32

    SDL2::TTF.init
    @font = SDL2::TTF.open(Pathname(font_file).expand_path.to_s, font_size)
    @font.kerning = true
  end

  def after_view
    system_line "#{frame_counter} #{fps}fps"

    super
  end

  def system_line(text)
    rect = SDL2::Rect.new(0, 0, *@font.size_text(text))

    renderer.draw_blend_mode = SDL2::BlendMode::NONE
    renderer.draw_color = [0, 0, 128]
    renderer.fill_rect(rect)

    font_color = [255, 255, 255]
    texture = renderer.create_texture_from(@font.render_blended(text, font_color))
    renderer.copy(texture, nil, rect)
  end
end

Base.prepend FontMethods

# Base.run

テキスト表示するだけでこんなに書かんといけんのかって感じだけど使いやすいメソッドを作っておけばよさそう。

これで左上にカウンタと秒間フレーム数を表示できた。

font

super だらけなのが気になる

メソッドを Rails の before_action のようなブロックに置き換えると良いかもしれない。

require "active_support/callbacks"
require "active_support/core_ext/object/blank"

class Foo
  include ActiveSupport::Callbacks
  define_callbacks :view

  def self.view(*args, &block)
    set_callback(:view, *args, &block)
  end

  def run
    run_callbacks :view
  end

  view do
    p 1
  end
end

class Bar < Foo
  view do
    p 2
  end
  view do
    p 3
  end
end

Bar.new.run
# >> 1
# >> 2
# >> 3

view の定義が追加になっているのがわかる。
ただし super を呼ばずにオーバーライドする選択の余地がなくなるので一長一短ある。
なのでミニフレームワークへの適用はいったん置いとく。

ベクトルライブラリを入れる

x y を個別に計算するのが面倒なのでベクトルライブラリを入れる。
とりあえず gem search vector で出てきた vector2d を使ってみる。

shell
bundle add vector2d

こんな感じで使えるようだ。

vec = Vector2d(3, 4)   # => Vector2d(3,4)
vec.x                  # => 3
vec.y                  # => 4
vec.length             # => 5.0
vec * 2                # => Vector2d(6,8)
vec * Vector2d(2, 2)   # => Vector2d(6,8)
vec + Vector2d(1, 1)   # => Vector2d(4,5)
vec.normalize.length   # => 1.0
vec.to_a               # => [3, 4]

が、ここで罠があった。
このライブラリめちゃくちゃ重い!

こんな数学系のライブラリなんかとくに速度を意識して実装してくれてると思うじゃん。
なんならネイティブ実装してくれててもおかしくないと思うじゃん。
だから、このあとで作るデモが 15 FPS になった原因がまさか Vector2d にあるとは信じられんかった。

Ruby の標準ライブラリだったけど、いつのまにか外部 gem になっていた matrix (に含まれるVectorライブラリ) と速度を比較してみると──

require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "matrix"
  gem "vector2d"
  gem "benchmark-ips"
end

Benchmark.ips do |x|
  x.report("Vector")   { Vector[2, 3] + Vector[4, 5]     }
  x.report("Vector2d") { Vector2d(2, 3) + Vector2d(4, 5) }
  x.compare!
end

# Warming up --------------------------------------
#               Vector    49.777k i/100ms
#             Vector2d     1.702k i/100ms
# Calculating -------------------------------------
#               Vector    493.319k (± 1.8%) i/s -      2.489M in   5.046831s
#             Vector2d     23.293k (±11.6%) i/s -    115.736k in   5.061193s
#
# Comparison:
#               Vector:   493319.3 i/s
#             Vector2d:    23293.3 i/s - 21.18x  (± 0.00) slower

なんと 21.18 倍の遅さ。
何をやったらこんなに遅くなるのかわからないけどクリエイティブコーディングに向いてないことだけはわかる。
じゃあなんで matrix を最初から使わなかったかというと使いづらいところがあるから。
でもいいのがないので matrix のベクトルクラスを改良してみる。
さっきの vector2d は消して matrix を入れよう。

shell
bundle remove vector2d
bundle add matrix
class Vector2d < Vector
  def +(v)
    if v.kind_of?(self.class)
      super
    else
      super(self.class[v, v])
    end
  end

  def -(v)
    if v.kind_of?(self.class)
      super
    else
      super(self.class[v, v])
    end
  end

  def *(v)
    if v.kind_of?(self.class)
      map2(v) { |a, b| a * b }
    else
      super
    end
  end

  def /(v)
    if v.kind_of?(self.class)
      map2(v) { |a, b| a / b }
    else
      super
    end
  end
end

def Vector2d(*args)
  Vector2d[*args]
end

できた。上のようにオーバーライドしたので次のように書けるようになった。

Vector2d(2, 3) + 1              # => Vector[3, 4]
Vector2d(2, 3) - 1              # => Vector[1, 2]
Vector2d(2, 3) * Vector2d(2, 3) # => Vector[4, 9]
Vector2d(2, 3) / Vector2d(2, 3) # => Vector[1, 1]

これで使いやすくなったとはいえ matrix のベクトルライブラリは機能優先の多次元対応版なので、速度面でクリエイティブコーディングには向いてない。
今回は matrix 版を使うとしても今後は2D専用のシンプルなものに置き換えた方がよさそう。

仮に実装してみると──

require "matrix"
require "benchmark/ips"

class MyVector
  attr_accessor :x, :y

  def initialize(x, y)
    @x, @y = x, y
  end

  def +(other)
    self.class.new(@x + other.x, @y * other.y)
  end
end

Benchmark.ips do |x|
  x.report("Vector")   { Vector[1, 2] + Vector[3, 4]             }
  x.report("MyVector") { MyVector.new(1, 2) + MyVector.new(3, 4) }
  x.compare!
end

# Warming up --------------------------------------
#               Vector    59.187k i/100ms
#             MyVector   138.991k i/100ms
# Calculating -------------------------------------
#               Vector    588.342k (±10.0%) i/s -      2.900M in   5.000701s
#             MyVector      1.371M (± 4.3%) i/s -      6.950M in   5.078438s
#
# Comparison:
#             MyVector:  1371388.1 i/s
#               Vector:   588342.3 i/s - 2.33x  (± 0.00) slower

2.33 倍速くなった。

要点

  • vector2d は信じられないほど遅い
  • matrix は vector2d より 21.18 倍速い
  • 2D専用を自作すれば matrix より 2.33 倍速くなる

Tixy クローンのベースを作ってみる

Tixy (Creator: @aemkei) は短いコードでさまざまな模様の表現を試みるクールなサイトだ。
リスペクトの気持ちを持ってこれをまねてみる。

具体的には

  • Time
  • Index (セルの連番)
  • X 座標
  • Y 座標

を受けとって返した -1.0..1.0 の値に応じてセルの色と大きさが変わる仕組みになっている。

class TixyCloneApp < Base
  CELL_N = 16

  def setup
    super

    @window_rect    = Vector2d(*window.size)
    @cell_wh        = @window_rect * 1.0 / CELL_N
    @inner_top_left = @window_rect * 0.5 - @cell_wh * CELL_N * 0.5
  end

  def before_view
    renderer.draw_color = [0, 0, 0]
    renderer.clear
  end

  def view
    super

    time = SDL2.get_ticks.fdiv(1000)
    index = 0
    CELL_N.times do |y|
      CELL_N.times do |x|
        r = tixy_func(time, index, x, y)
        if r.nonzero?
          r = r.clamp(-1.0, 1.0)
          center = @inner_top_left + @cell_wh * Vector2d(x, y) + @cell_wh * 0.5
          radius = @cell_wh * 0.5 * r.abs * 0.95
          top_left = center - radius
          renderer.draw_color = tixy_color(r)
          renderer.fill_rect(SDL2::Rect.new(*top_left, *(radius * 2)))
        end
        index += 1
      end
    end
  end

  def tixy_func(t, i, x, y)
    sin(t - sqrt((x - 7.5)**2 + (y - 6)**2))
  end

  def tixy_color(v)
    if v.positive?
      v = 1.0
    else
      v = -1.0
    end
    c = v.abs * 255
    if v.positive?
      [c, c, c]
    else
      [c, 0, 0]
    end
  end

  run
end

tixy_clone_base
sin(t - sqrt((x - 7.5)**2 + (y - 6)**2))

  • tixy_func 関数の計算を工夫するだけでいろんな表現ができる
  • 16 * 16 = 256 個の四角形を描画しても60FPSでぬるぬる動いている

使ってみた所感

  • Window モードでも垂直同期できるようになって嬉しい
    • Ruby/SDL だとなぜかフルスクリーンのときしか垂直同期できなかった
  • ruby コマンドで動くようになって嬉しい
    • Ruby/SDL 時代は ruby のかわりに rsdl で起動しないと動かなかった
    • 起動するだけありがたかったとはいえ、この罠にしょっちゅうはまった
  • SGE がなくても描画できるようになって嬉しい
    • Ruby/SDL 時代は SGE を別途入れないとろくに描画できなかった
    • この SGE のインストールが激ムズ
      • どこからダウンロードしてくればいいのかもわからない
        • 現在も消息不明
      • さらに謎のパッチを当てないといけない
    • そのせいか Ruby/SDL 本体にこっそりバンドルされていた
      • ビルドオプションをつけると一緒にインストールできた
  • 使いやすくなっている気がする
    • draw_color で色だけ指定できるところとか
    • PRESENTVSYNC だけで垂直同期とか
  • 速くなっている気がする
    • Ruby/SDL で処理落ちする描画数でも60fpsを維持していた
  • 日本語ドキュメントは(いまのところ)ないので
  • Nannou とは立ち位置が大きく異なる
    • Nannou は
      • フレームワーク
      • 終了や全画面化のキーバインディング設定済み
      • クリエイティブコーディングとやらをすぐに始められる
      • ベクトルのライブラリがめちゃくちゃ作り込まれている
      • 領域を扱うクラスも作り込まれている
      • どこに何を書くかが厳密に決まっている
      • ただ Rust がムズすぎる
    • Ruby/SDL は
      • ラッパー
      • すべてユーザーまかせ
        • Escape とか押しても終了しない
          • メインループ自体自分で書く
          • 面白半分に全画面にしたりするとバグったときPCを落とさないと復帰できなかったりする
        • FPS 値をすぐに知るメソッドがない
        • ベクトルライブラリがない
        • 領域を扱う便利クラスがない
          • Rect はあるけどほぼ x, y, w, h を保持する程度の役割り
        • どこに何を書くかが決まってない
      • それがいい

参照

https://github.com/ohai/ruby-sdl2
http://ohai.github.io/ruby-sdl2/doc-en/

Discussion

ログインするとコメントできます