mieki256's diary



2013/12/19(木) [n年前の日記]

#1 [dxruby][game] DXRubyと疑似乱数

_DXRuby Advent Calendar 2013 の 19日目です。

17日、18日目の記事は、GameKazuさんの、 _DXRubyでRPGを作る_DXRubyでRPGを作る(2) でした。自分、RPGは作ったことが無いので、大変勉強になりました…。

今回は、「DXRubyと疑似乱数」というお題で書かせていただきます。個人的に、前から少し気になってた部分なので、せっかくだからこの機会を利用して検証させてもらおうかなと。

「疑似乱数? DXRubyと関係ないじゃん」と思われる方もおられましょうが。 という流れで、全く関係ない話ではないよね? と。そんなわけで、大目に見てもらえればと。

とりあえず。 この流れで話を進めていこうかと。中級者以上の方は、「あーハイハイ。知ってる知ってる」と、全部読み飛ばしてしまってOKですよ。

最初に謝っておきます。やたらと長い記事になってしまってゴメンナサイ…。

それでは、初心者向けの解説から。

Q. 疑似乱数って何? :

A. 要するに、サイコロです。どんな数が出てくるのか予測できない数。でたらめな数。それを乱数と言います。「乱れた数」と書きますが、つまり、そこに、ルールだの法則だのが無いように見える数、ということですね。

ただ、コンピュータ上の乱数は、パッと見では乱数に見えますが、真の乱数ではありませんので、疑似乱数と呼ばれています。

ふと思いましたが、「乱れた数」があるなら、「みだらな数」「淫数」もあるのでしょうか? 例えば、隠しコマンドを入れると残機数が「69機」になるとか…。うむ。これは「淫数」な気がする。…どうでもいいか。

Q. 疑似乱数ってゲームのどこで使われてるの? :

A. 思いつくのは…。以下のような場面で使われたりします。
  • 敵の動きや、発生位置を、乱数を使って決めてたり。
  • 爆発等のエフェクト種類や、初期位置、速度を、乱数を使って決めてたり。
  • サイコロを振る時に使ったり。
  • じゃんけんする時に使ったり。
  • カードやマージャン牌を、かき混ぜる時に使ったり。(でも、実は…ちゃんとかき混ぜてないんですけどね…)
まあ、ゲームによって、色々です。

Q. ゲームに使う疑似乱数って、何を使ってもいいの? :

A. 個人的な印象ですけど、疑似乱数をゲームに使う場合、以下の3つの条件は満たしてないとダメかなあ、と思ってます。まあ、作るゲームにもよりますけど。
  • 処理時間が短いこと。
  • 再現性があること。(疑似乱数の種が初期化できること)
  • 妙な周期性がないこと。

処理時間は…。乱数一つゲットするのに、何フレームもかかってたら、ゲームになりませんよね。ですので、とにかく一瞬で得られること。この条件は、絶対に外せません。

再現性は…。「初期化してから使えば、毎回、同じ並びの乱数が得られるか?」ということですね。プログラミング言語によっては、疑似乱数の初期化メソッドがそもそも存在しないものもありまして。その場合、当然再現性なんか期待できませんから、乱数生成器を自作することになります。

周期性は…。「似たような乱数の並びが繰り返し出てきちゃったりしないか?」ということです。

Q. 疑似乱数の再現性って、大事なの? :

A. ゲームの仕様によりますが、ほとんどの場合は、大事だと思います。

例えば。昔風の2Dゲームにおいて、乱数の再現性は、リプレイ機能、デモプレイ画面を作る時に、とても重要です。敵が、再現性のない乱数を使って動いていたら、その画面に入るたびに、全然違う動きになるわけで…。
  • 誰も居ない虚無の空間を、気でも触れたかのごとく、執拗に撃ち続けるプレイヤーキャラ。
  • そんな異常状態をスルーして、プレイヤーキャラを容赦なく殺害する、一介の雑魚敵達。
  • あっという間に残機ゼロ。わずか数秒で、デモプレイ画面終了。
  • 「…アレ? 今、一瞬、デモプレイ画面っぽいものが映ったような…気のせい?」
乱数の再現性が無いばかりに、画面の中は、そりゃもう大惨事さ! …まあ、昔のゲーセンでは、そんな大惨事のデモプレイ画面を、たまーに目にしましたが。見ていて胸が痛んだものです。 *1 *2

難易度調整や、バグチェックをする際にも、乱数の再現性が無いと苦労します。
  • プレイするたびに難易度がガラリと変わってしまう。(そこまで乱数に頼った作りをしちゃってる時点で大問題、という話も…)
  • プレイヤーキャラのワーク(変数)をいくら整えても、敵が同じ動きをしてくれなくて、最悪、何度やってもバグが再現できない。
これは地獄です…。今日も泊まり込み…。椅子寝かな…。体が臭い…。風呂入りたい…。

つまり、風呂に入れる生活をしたいなら、乱数の再現性は重要なのですね。…や、コレ、冗談めかして書いてるけど、結構マジで死活問題で。

まあ、別に乱数に限った話でもなくて。「動かすたびに全然違う結果が出てくるプログラム」では、えてして大惨事になりますので、コンピュータの世界では、再現性は重要なのですが。

Q. 疑似乱数の周期性って、あったらマズイの? :

A. ゲームによっては、致命的ですね…。製品回収にすらなってしまう場合も。以下の記事が参考になるかと。

_「カルドセプトサーガ」にダイス目が偶数と奇数を繰り返すバグ | スラッシュドット・ジャパン
_カルドセプトサーガの乱数問題 | ψ(プサイ)の興味関心空間

サイコロの目が、偶数→奇数→偶数→奇数と出てくる周期性が ―― つまり、乱数の最下位ビットが、必ず 0→1→0→1 を繰り返す、という話で…。次に出てくるサイコロの目が、そこそこ予想できちゃうのは、ちょっと厳しいですね。

もっともこのあたり、サイコロを使うゲームだから問題になったのであって、シューティングゲームやアクションゲームでは、あまり関係ないんじゃないか、という気もしますけど。でも、どうせなら、妙な周期性がないほうが、安心して使えますよね。

Q. DXRuby で使える疑似乱数って、何があるの? :

A. DXRuby は Ruby のライブラリですから…。Ruby が標準で持ってる疑似乱数を使うのが一般的でしょう。

調べてみたら、以下の2つの書き方があると知りました。
srand(整数) # 疑似乱数を初期化する(種を設定する)

rand(整数) # 0〜(整数-1)の範囲で、疑似乱数を得る。


※ 以下は、Ruby 1.8.7 では使えない。

r = Random.new(整数) # 疑似乱数を初期化する(種を設定する)

r.rand(整数) # 0〜(整数-1)の範囲で、疑似乱数を得る。
  • 整数を指定する場合は、rand() も Random#rand() も同じ処理をするらしいです。
  • 浮動小数点数(float)を指定する場合もありますが、今回はゲームに使えるかどうかで考えてますので、とりあえず整数の乱数が得られれば、なんとかなるかなと。
  • Random クラスは、Ruby 1.8.7 では使えません。ただ、Ruby 1.8.7 は、2013/06末でサポートが打ち切られましたので、これから使う人は居ないでしょうし、気にしなくていいかも?
自分は Ruby 初心者なので、このあたり自信ありませんで。間違ってたらツッコミ入れといてくださいです。

Q. じゃあ、Rubyの rand() を使えばいいんだね? :

A. さて、どうなんでしょうね…。自分が気になっていたのは、このあたりで。

上にも書きましたが。
  • 処理時間が短いこと。
  • 再現性があること。(疑似乱数の種が初期化できること)
  • 妙な周期性がないこと。
この条件を満たしていれば、安心して使える疑似乱数だろうと思います。加えて、
  • Rubyのバージョンによって、違う乱数が出てこないか。
  • rand() と Random#rand() で、処理時間が大きく違ったりしないか。
このあたりも、気になるところ。作ったゲームを Ruby 1.9.3 上で動かす分には遊べるのに、Ruby 2.0.0 になったら乱数の並びが変わっちゃって難易度ハネ上がった、なんて展開は困ります。

実はそのあたり、Rubyのドキュメントに、答えは掲載されているのですが…。

_class Random

でも、ホント? ホントにそうなの? ここがもし間違ってると、後で泣くのはこっちだぜ?

そんなわけで、一応自分の手元でも検証してみることにしたのでした。

再現性についての検証。 :

まずは再現性について、ざっくり検証してみます。

検証に使ったスクリプトソースとその結果は、随分と長くなってしまったので、この記事の一番最後に載せておきますね。

また、使った環境は、以下の通りです。
  • CPU : Intel Core i5 2500 (3.3GHz)
  • Windows7 x64
  • Ruby 1.8.7 mswin32版
  • Ruby 1.8.7 mingw32版
  • Ruby 1.9.3 mingw32版
  • Ruby 2.0.0 mingw32版

さて、検証結果ですが。
  • 再現性はある。
  • 少なくとも、Ruby 1.8.7、Ruby 1.9.3、Ruby 2.0.0 は、同じ乱数が得られる。
  • rand(整数) と Random#rand(整数) は、同じ乱数が得られる。
  • rand(整数) と Random#rand(整数) の実行時間は、どちらもほとんど同じ。
  • 処理時間も…まあ、おそらく、大丈夫そう。乱数を得る際に、めっちゃ時間がかかってるようには見えません。
良かった良かった。ここまでは、一安心。

余談ですが、ついでに以下のことも分かりました。
  • Ruby はバージョンが上がると、グングン処理速度が速くなってる。
  • mswin32版より、mingw32版のほうが、処理速度は速い。かもしれない。(Ruby 1.8.7しか検証してないので、Ruby 1.9以降は、違ってるかも?)

周期性について検証。 :

周期性も確認しておきましょう。

とりあえず、某ゲームの乱数と同様に、乱数の最下位ビットが、0→1→0→1 を繰り返してないか、画像にして眺めてみましょうか。ついでに、乱数の種も変えてみて、同じ状態が続かないか確認してしまいましょう。

ちなみに、画像生成には、DXRuby を使ってます。
# 乱数の最下位ビットを画面に描画してみる

require 'dxruby'

Window.resize(160,120)

# 画像を新規作成
img = Image.new(Window.width, Window.height)

# 乱数初期化の種
lst = [
       [0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15],
       [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
      ]

# 乱数初期化の種を変えながら、画像に、乱数の最下位ビットを点として打つ
y = 0
lst.each do |dt|
  dt.each do |i|
    srand(i) # 乱数初期化
    puts "srand(#{i})"
    Window.width.times do |k|
      img[k, y] = ((rand(65536) & 0x01) == 0)? C_BLACK : C_WHITE
    end
    y += 1
  end
  y += 16
end

# 画像をファイル保存
img.save("rand_result.png", FORMAT_PNG)

Window.loop do
  break if Input.keyPush?(K_ESCAPE)
  Window.draw(0, 0, img) # 画面に描画
end

結果画像は以下になりました。見づらいので4倍に拡大してあります。
最下位ビットの検証画像

0→1→0→1 の周期性があれば、綺麗な市松模様が出てくるはずですが…そうは見えませんね。どうやら、最下位ビットが妙な周期性を持ってるわけではなさそうです。

だけど、まだ不安なので、乱数を使ってひたすらドットを打ってみます。

「そんな検証をして意味あるの?」と言われそうですが、意味がある時もあったりします。と言うのも、以前、自分で乱数生成器を作った際、こういった感じの検証をしたら、整然と並んだ綺麗な模様が出現しまして。それってつまり、妙な周期性があるということで…。

まあ、その時は、ビットシフトして下位ビットを捨ててみたら、乱数っぽい状態になってくれたので、それでお茶濁ししてしまったのですけど。疑似乱数を作るアルゴリズムによっては、下位ビットが妙な周期性を持つ場合も多く、そんな時は、ビットシフトして下位ビットを捨ててしまう手も使われるのだそうです。もちろん、上位ビット分にも周期性がないか、検証する作業が必要になりますが。
# 乱数でドットを打ってみる

require 'dxruby'

# 画像を新規作成
img = Image.new(Window.width, Window.height, C_BLACK)

srand(1)
Window.height.times do |y|
	Window.width.times do |x|
		c = rand(256)
		img[x, y] = [c, c, c]
	end
end

# 画像をファイル保存
img.save("rand_result2.png", FORMAT_PNG)

Window.loop do
  break if Input.keyPush?(K_ESCAPE)
  Window.draw(0, 0, img) # 画面に描画
end
ランダムにドットを打ってみた画像

整然と並んだ綺麗な模様は、別に出てきていませんよね…。どうやら大丈夫そうかな…。たぶん…。

Q. 結論としてどうなの? Ruby の乱数はゲーム制作に使えるの? :

A. 少なくとも、現時点では、安心して使えそうです。ただし、各シーンの、キリのいいタイミングで ―― 例えばステージ開始時の初期化処理中に、疑似乱数の種を初期化する、等をしてから使うことを忘れずに。

もっとも、コレ、ドキュメントに書いてある通りになっただけ、なんですけど…。まあ、自分で検証してその通りになりましたから、「よしっ! バッチグー!」ということで。

Ruby のバージョンが変わったら、また検証してみたほうがいいのかもしれません。もっとも、Ruby 1.8、1.9、2.0 と、結果は同じでしたから、今後も再現性は保証されるのではないかと予想しますけど。

そもそもゲームに乱数なんか使わないよ、という話。 :

ここまで、「ゲームに乱数を使う」という前提で話をしましたが。

実は、「ゲーム制作に乱数を使うべきではない」という主張もあります。以下の記事を読んでいただければ分かるかと。

_ゲーム作るのにまだ乱数使ってるの? - 2010-02-05 - ABAの日誌

要するに、「完璧なゲームデザインができているなら、乱数が入り込む隙など無いはずだ」という主張ですね。それはたしかに、そうかもしれない。自分も少し心当たりがあります。アクションゲームの類でも、敵の動きや、発生テーブルを作り込んでいくと、乱数を使う箇所がどんどん減っていく気がしますね…。 *3

とは言え。件の記事でも言及されていますが…。
  • プロトタイプをサクッと作ってる時に、なんとなく敵がそれっぽく動いてるようにしたいとか。
  • プレイヤーの必勝パターンができてしまうのを軽く防止したいとか。
等々、乱数使用が有効な場面もありますので、「絶対に乱数を使わないぞ!」と意固地になる必要もないだろうと自分は思います…。

DXRubyはゲーム制作以外にも使えますよ、という話。 :

上の方で、DXRubyを使って、検証結果を画像化したのですが。

DXRubyを使って、こういうことができるという点も、実は大事じゃないかと個人的には思っているのです。

「ゲーム制作ライブラリ」と銘打たれていると、「ゲーム制作? 俺には関係ないや」とスルーする方も多いと想像するのですが。それはおそらく早計で。

「ゲーム制作に使えるぐらいだから、こういうことだって軽くできちゃうはずだよな?」と思い直すことができれば、意外なところで、ソレを使って楽ができる…。そんな場面もあるだろうなと。

そして、それは、おそらく逆も成り立つのだろうと。「ゲーム制作には関係ないや」と思い込んでた、プログラミング言語やライブラリが、ゲーム制作を楽にしてくれる時だってあるはずだと。

そもそも、Rubyの作者様だって、Rubyを使って、グリグリ動くリアルタイムゲームが作れるなんて、夢にも思ってなかったはずですから…。DXRubyの存在自体が、「その発想は無かったわ」的事例の一つ、かもしれませんよね。

つまるところ、この記事は、検証作業に DXRuby を軽く使ってみることで、

「○○用と謳われていても、○○にしか使えないというわけじゃない」
「目の前の道具を、柔軟性を持って捉えることも、プログラマーには必要」
「そんな姿勢を心掛けていれば、いつかどこかで、その道具が自分に楽をさせてくれる、かもしれない」

―― そんな考え方を示すための、簡素な事例の一つとして書いてみたつもり、でもあるのでした。まあ、「立っている者は親でも使え」という話に過ぎませんが。

それでは、明日は、あおたくさんの _ゲーム作りました をお楽しみくださいませ。

関連資料。 :

疑似乱数、あるいは、Rubyで扱える乱数についての、解説ページも紹介しておきます。

_良い乱数・悪い乱数
_Ruby の標準乱数生成器とその改善案
_Rubyにおけるrand(乱数)の挙動について - yayuguのにっき

特に、乱数生成器を自分で実装する際には、 _良い乱数・悪い乱数 は必読ではないかなと個人的には思います。ありがたや。

再現性の検証に使ったソースと結果。 :

# 乱数の再現性を確認する

require 'benchmark'

n = 1024 * 1024 * 4 # 乱数を発生させる回数

lst_rand1 = Array.new(n)
lst_rand2 = Array.new(n)
lst_random1 = Array.new(n)
lst_random2 = Array.new(n)

# ベンチマークを取る
Benchmark.bm(14) do |x|

  unless /^1.8/ =~ RUBY_VERSION
    # Ruby 1.8.7 は Random クラスを持たないので
    # 処理をスキップする
    
    # 2回乱数を作ってみて、後で比較する
    r = Random.new(0) # 乱数を初期化
    n.times do |i|
      lst_random1[i] = r.rand(0x7fffffff)
    end
    
    r = Random.new(0) # 乱数を初期化
    n.times do |i|
      lst_random2[i] = r.rand(0x7fffffff)
    end

    tmp = 0
    x.report("random:") {
      n.times { tmp = rand(0x7fffffff) }
    }
    
    x.report("random:") {
      n.times { tmp = rand(0x7fffffff) }
    }
  end
  
  # 2回乱数を作ってみて、後で比較する
  srand(0) # 乱数を初期化
  n.times do |i|
    lst_rand1[i] = rand(0x7fffffff)
  end
  
  srand(0) # 乱数を初期化
  n.times do |i|
    lst_rand2[i] = rand(0x7fffffff)
  end

  tmp = 0
  x.report("rand:") {
    n.times { tmp = rand(0x7fffffff) }
  }

  x.report("rand:") {
    n.times { tmp = rand(0x7fffffff) }
  }

end

# 乱数を記録した配列の並びを比較してみる

if lst_rand1 == lst_rand2
  puts "rand は同じ並びです"
else
  puts "rand の並びは異なります"
end

unless /^1.8/ =~ RUBY_VERSION
  if lst_random1 == lst_random2
    puts "random は同じ並びです"
  else
    puts "random の並びは異なります"
  end

  if lst_rand1 == lst_random1
    puts "rand と random は同じ並びです"
  else
    puts "rand と random の並びは異なります"
  end
end

# バイナリファイルとして出力してみる

fn1 = "result_rand_" + RUBY_VERSION + ".bin"
File.open(fn1, 'w+b') do |file|
  file.write(lst_rand1.pack("N*"))
end

unless /^1.8/ =~ RUBY_VERSION
  fn2 = "result_random_" + RUBY_VERSION + ".bin"
  File.open(fn2, 'w+b') do |file|
    file.write(lst_random1.pack("N*"))
  end
end

結果はこうなりました。
> pik list
  187: ruby 1.8.7 (2010-12-23 patchlevel 330) [i386-mswin32]
  187: ruby 1.8.7 (2012-10-12 patchlevel 371) [i386-mingw32]
  192: ruby 1.9.2p290 (2011-07-09) [i386-mingw32]
* 193: ruby 1.9.3p484 (2013-11-22) [i386-mingw32]
  200: ruby 2.0.0p353 (2013-11-22) [i386-mingw32]

# ----------------------------------------
# Ruby 1.8.7 mswin32版とmingw32版を比較
#
# ※ Ruby 1.8.7 は、2013/06/30にサポート対象外になっている

> pik 187
Select which Ruby you want:
1. 187: ruby 1.8.7 (2012-10-12 patchlevel 371) [i386-mingw32]
2. 187: ruby 1.8.7 (2010-12-23 patchlevel 330) [i386-mswin32]
?  2

> ruby rand1.rb
                    user     system      total        real
rand:           4.305000   0.000000   4.305000 (  4.308247)
rand:           3.838000   0.000000   3.838000 (  3.845220)
rand は同じ並びです

> pik 187
Select which Ruby you want:
1. 187: ruby 1.8.7 (2012-10-12 patchlevel 371) [i386-mingw32]
2. 187: ruby 1.8.7 (2010-12-23 patchlevel 330) [i386-mswin32]
?  1

> ruby rand1.rb
                    user     system      total        real
rand:           3.915000   0.000000   3.915000 (  3.915224)
rand:           3.557000   0.000000   3.557000 (  3.558204)
rand は同じ並びです

# mswin32版よりmingw32版のほうが処理速度は速い

# ----------------------------------------
# Ruby 1.9.7 mingw32版で検証

> pik 193
> ruby rand1.rb
                     user     system      total        real
random:          2.356000   0.000000   2.356000 (  2.353134)
random:          2.356000   0.000000   2.356000 (  2.356135)
rand:            2.465000   0.000000   2.465000 (  2.451140)
rand:            2.449000   0.000000   2.449000 (  2.449140)
rand は同じ並びです
random は同じ並びです
rand と random は同じ並びです

# Ruby 1.8.7 より Ruby 1.9.3 のほうが2倍近く速い

# ----------------------------------------
# Ruby 2.0.0 mingw32版で検証

> pik 200
> ruby rand1.rb
                     user     system      total        real
random:          2.121000   0.000000   2.121000 (  2.114121)
random:          2.122000   0.000000   2.122000 (  2.123121)
rand:            2.246000   0.000000   2.246000 (  2.247129)
rand:            2.278000   0.000000   2.278000 (  2.266129)
rand は同じ並びです
random は同じ並びです
rand と random は同じ並びです

# Ruby 1.9.3 より Ruby 2.0.0 のほうが僅かに速い

# ----------------------------------------
# ファイル保存した乱数列を比較

> fc /b result_rand_1.8.7.bin result_rand_1.9.3.bin
ファイル result_rand_1.8.7.bin と RESULT_RAND_1.9.3.BIN を比較しています
FC: 相違点は検出されませんでした

> fc /b result_rand_1.8.7.bin result_rand_2.0.0.bin
ファイル result_rand_1.8.7.bin と RESULT_RAND_2.0.0.BIN を比較しています
FC: 相違点は検出されませんでした

> fc /b result_rand_1.8.7.bin result_random_1.9.3.bin
ファイル result_rand_1.8.7.bin と RESULT_RANDOM_1.9.3.BIN を比較しています
FC: 相違点は検出されませんでした

> fc /b result_rand_1.8.7.bin result_random_2.0.0.bin
ファイル result_rand_1.8.7.bin と RESULT_RANDOM_2.0.0.BIN を比較しています
FC: 相違点は検出されませんでした

# Ruby 1.8.7、Ruby 1.9.3、Ruby 2.0.0 の結果は同じ
# Kernel.#rand(整数) と Random#rand(整数) の結果は同じ

*1: もっとも、太古のTVゲームは、CPUのリフレッシュカウンタ等を読んで ―― ハードウェアで乱数を作っていたそうで。それでは再現性が得られなかったから、大惨事な画面も、仕方なかったのかもしれません。
*2: これは、60FPSで動作することが保障されてる、昔ながらの2Dゲームの場合の話でして。3Dゲームになると、可変フレームレートになるので、事情が変わってくるはずです。フレーム毎の、プレイヤーのコントローラ操作を再現しても、毎回フレームレートが同じではないため、ゲーム展開にずれが出てきます。ですから、おそらく、座標値や状態を記録して、リプレイやデモプレイを実現している場合が多いのではないかと想像していますが…。
*3: 例えばですが、プレイヤーとの間合い、プレイヤーの位置、プレイヤーの状態をチェックして、自分の動作を決定していく、そういう敵の動かし方もあるわけです。たしか、「ワンダと巨像」は、地面に、巨像の動きを決定するための判定マップを設定して動作を決定してた、という記事を読んだ記憶が…。そういう動かし方なら、乱数が入ってくる箇所も少なくなりそうですよね…。

以上です。

過去ログ表示

Prev - 2013/12 - Next
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31

カテゴリで表示

検索機能は Namazu for hns で提供されています。(詳細指定/ヘルプ


注意: 現在使用の日記自動生成システムは Version 2.19.6 です。
公開されている日記自動生成システムは Version 2.19.5 です。

Powered by hns-2.19.6, HyperNikkiSystem Project