文字列表示


数値を高速で書き換える (2005/12/3)
 draw_textによる書き換え
 src_rectの移動による書き換え
 Viewport指定と画像の移動による書き換え
 1桁の数字画像を取り替えることによる書き換え
 各描画方法の負荷比較

文字列キャッシュ (2006/1/7)

文字列表示スクリプト素材の使い方



● 数値を高速で書き換える
新作の『ウニクラッシャー』ですが、一部で動作が重いという声が寄せられております。
原因ははっきりしておりまして、ゲーム中の頻繁な文字(特に数値)の書き換えにあります。

そんな折に同じく第7回3分ゲーコンテスト参加者のトリアコンタンさん
まさにタイムリーにこの問題の解決策を示されました。
そこで私も本気で高速化法を検討してみようと思い立ち、今回のテーマとなりました。


draw_textによる書き換え

本題に入る前に、まずは画面上に文字を表示するおさらいをします。
text = Sprite.new
text.bitmap = Bitmap.new(33,22)
text.bitmap.draw_text(0,0,33,22,"123")

1行目で作成されたSpriteのオブジェクトは画像を置くためのパネルみたいなものです。
2行目で作成されたBitmapのオブジェクトは画像データそのもので、
この段階では横33ピクセル、縦22ピクセルの透明画像です。
そこにdraw_textで文字を書き込んでいます。
ゲーム中に登場する文字は全てこのような画像情報として扱われています。

これを踏まえて、自動で数値を描画、書き換えをしてくれるクラスを作ってみます。
#==============================================================================
# ■ Scoreview0
#------------------------------------------------------------------------------
#  数字表示クラス(draw_textによる書き換え)
#==============================================================================

class Scoreview0 < Sprite
  #--------------------------------------------------------------------------
  # ● オブジェクト初期化
  #--------------------------------------------------------------------------
  def initialize(x, y, value, size = 22)
    super()                                #Spriteクラスを引き継ぐ
    @prevalue = value                      #値の変更を検知するための比較用変数
    @size = size                           #フォントサイズ
    @digit = 1                             #桁数
    @digit += 1 while value >= 10 ** @digit #桁数を調べる
    self.bitmap = Bitmap.new(@digit*@size/2, @size) #文字描画用ビットマップ作成
    @predigit = @digit                     #桁数の変更を検知するための比較用変数
    self.x = x                             #スプライトのx座標
    self.y = y                             #スプライトのy座標
    self.bitmap.font.size = @size          #フォントサイズ指定
    self.bitmap.draw_text(0, 0, @digit*@size/2, @size, "#{value}")#文字描画
  end
  
  #--------------------------------------------------------------------------
  # ● 更新
  #--------------------------------------------------------------------------
  def move(value = @prevalue)
    if value != @prevalue                    #値が変わったら
      @prevalue = value                      #次回比較のため現在の値を指定
      @digit = 1                             #桁数を調べる
      @digit += 1 while value >= 10 ** @digit #桁数を調べる
      if @digit == @predigit                 #前回と桁数が同じなら
        self.bitmap.clear                    #ビットマップに描画された文字を消去
      else                                             #前回と桁数が違うなら
        self.bitmap = Bitmap.new(@digit*@size/2, @size)#新しくビットマップを作成
      end
      self.bitmap.draw_text(0, 0, @digit*@size/2, @size, "#{value}")#文字描画
    end
  end
end

Bitmap.new(@digit*@size/2, @size)とかちょっとややこしいかと思いますが、
これは桁数を調べて、桁数とフォントサイズからビットマップの大きさを自動調整しています。
数字の大きさは、横がフォントサイズの半分(半角文字だから)、縦がフォントサイズと同じです。
ですから横は"桁数×フォントサイズの半分"でちょうどいいビットマップサイズが得られます。

さて、こういうクラスを作って何が良いかと言いますと、
数値を画面に表示したい時の記述が簡単になります。

値を表示したい時は、
score = Scoreview0.new(x座標, y座標, 値[, フォントサイズ])

値を変えたい時は、
score.move(値)

ね、簡単でしょう?

では本題です。
こういう描画方法の処理速度がどれくらいになるのかを調べてみます。
Graphics.frame_rate = 120
scores = Array.new(22){|i|Scoreview0.new(300, i*22, 9999999999)}

loop do
  scores.each{|i|i.move(rand(10000000000))}
  Graphics.update
end

10桁の乱数22個を1フレームごとに書き換えるという極悪なスクリプトの完成です。
テストプレーモードで起動して、F2キーを押すとタイトルバーにFPSが表示されます。
私の環境(800MHz,256MB RAM)では大体11〜14FPSくらいです。
1秒間に120回描画する設定なのに、11〜14回しか書き換えられていないということは
かなり重たいということです。

ちなみにビットマップのサイズ調整を文字列全般に対応するなら以下のような感じになります。
value = "Test"
size = 22

text = Sprite.new
text.bitmap = Bitmap.new(1,1)
text.bitmap.font.size = size
text_size = text.bitmap.text_size("#{value}")
width = text_size.width
height = text_size.height
text.bitmap = Bitmap.new(width, height)
text.bitmap.font.size = size
text.bitmap.draw_text(0, 0, width, height, "#{value}")

これは『ウニクラッシャー』で使っていたものですが、
Bitmapを2回作成することでさらに深刻さを増しています。
普通に文字を描画するだけなら便利なスクリプトなんですけどね…。


src_rectの移動による書き換え

それでは数値描画の軽量化を順次検証してみます。
1つ目はこの記事を書くきっかけとなりましたsrc_rectの利用です。
以下はトリアコンタンさんのサイトからの引用です。
まず、0〜9までの数字を予め縦に描画しておきましょう。そして1フレーム毎に src_rect メソッドを用いて表示範囲を現在の数字のみを表示するように調整すれば内容を変えられます。

出典:http://www5f.biglobe.ne.jp/~delusion/tech/text_002.html
トップページ:Delusional Field

「縦に描画」というのが少々気になりますが、ともかく私なりの理解で書いてみます。
#==============================================================================
# ■ Scoreview1
#------------------------------------------------------------------------------
#  数字表示クラス(src_rectの移動による書き換え)
#==============================================================================

class Scoreview1
  #--------------------------------------------------------------------------
  # ● オブジェクト初期化
  #--------------------------------------------------------------------------
  def initialize(x, y, value, size = 22)
    @x = x                                   #x座標
    @y = y                                   #y座標
    @prevalue = value                        #値の変更を検知するための比較用変数
    @size = size                             #フォントサイズ
    @scores = Array.new                      #各桁用スプライトを置くための配列
    @numbers = Bitmap.new(@size * 50, @size) #数字画像セットのビットマップ作成
    @numbers.font.size = @size               #フォントサイズ指定
    @numbers.draw_text(0, 0, @size * 50, @size, "0123456789")#数字画像セット描画
    make_score(value)                        #作業の続きはmake_scoreで
  end
  
  #--------------------------------------------------------------------------
  # ● 更新
  #--------------------------------------------------------------------------
  def move(value = @prevalue)
    if value != @prevalue                    #値が変わったら
      @prevalue = value                      #次回比較のため現在の値を指定
      make_score(value)                      #作業の続きはmake_scoreで
    end
  end
  
  #--------------------------------------------------------------------------
  # ● 数値画像作成
  #--------------------------------------------------------------------------
  def make_score(value)
    digit = 0                                #桁数-1 兼 配列用ポインタ
    while value >= 0                         #数値が0以上なら
      digit1st = value % 10                  #1桁目を取り出す
      if @scores[digit] == nil               #この桁のスプライトがなかったら
        @scores[digit] = Sprite.new          #スプライト作成
        @scores[digit].x = @x - digit * @size / 2 #x座標指定(桁の位置を考慮)
        @scores[digit].y = @y                #y座標指定
        @scores[digit].bitmap = @numbers     #数字画像セットを置く
        @scores[digit].src_rect.width = @size / 2 #矩形の幅を調整
      end
      @scores[digit].src_rect.x = digit1st * @size / 2 #数値にあわせて矩形を移動
      value /= 10                            #1桁目を削る
      digit += 1                             #ポインタを進める
      if value == 0                          #数字がなくなったら
        while @scores[digit] != nil     #以前表示した画像の上位桁があるなら
          @scores[digit].src_rect.x = 0      #0を表示してみる
          digit += 1                         #ポインタを進める
        end
        break                                #終了
      end
    end
  end
end

右詰め・左詰めの違いとか桁数の足りない部分が0で埋まるなど前項の表示と少々
挙動が異なりますが、今回は描画方法による処理速度のみを問題にしますので、
細かい調整は省略しました。

make_scoreメソッドを作りましたが、
これは単にinitializeとmoveで同様の処理を使うためにまとめただけです。
"0123456789"と書いた画像を1桁ごとに表示して、見える部分のみを操作するという
やり方なので、桁を分割して処理する必要があります。
make_scoreメソッドの
value % 10
は1桁目の数字を取り出す操作、
value /= 10
は1桁目を削る操作です。これを数値が0になるまで繰り返します。

0になった後も、前に表示した値の上位桁がまだ残っている可能性がありますので、
そこにはとりあえず0を置きます。表示したくない場合は範囲外を指定するだけです。
いちいちdisposeするよりは効率的でしょう。

では前項と同じスクリプトを使って処理速度を見てみます。
Graphics.frame_rate = 120
scores = Array.new(22){|i|Scoreview1.new(300, i*22, 9999999999)}

loop do
  scores.each{|i|i.move(rand(10000000000))}
  Graphics.update
end

これで実行してみると大体45〜50FPSになりました。大きな改善です。

しかしもう少し効率化できそうです。
"0123456789"の文字列画像はこのクラスの全てのオブジェクトで共通なので
クラス内で作成するよりもモジュールで用意した方が良いかもしれません。

モジュールは何かと問われたら、私もまだよく分かっていないのですが、
どこからでも呼び出せるコモンイベントみたいなものだと思っています。
クラス内でBitmapを作ると、オブジェクトが作成されるたびに新しいメモリ領域に
新しくBitmapが作成されますが、モジュールで1つだけ作るようにすれば
全てのオブジェクトが同じBitmapを参照するという、この違いですね。

とりあえずRPG::Cacheを真似てみます。
#==============================================================================
# ■ Scoreview0
#------------------------------------------------------------------------------
#  数字画像をキャッシュするモジュール
#==============================================================================

module CacheNumber
  @cache = Hash.new                   #各サイズBitmapを保存するためのハッシュ
  
  #--------------------------------------------------------------------------
  # ● 数字画像セットのキャッシュ
  #--------------------------------------------------------------------------
  def self.numberset(size = 22)
    unless @cache.include?(size)        #ハッシュ内にキーが存在しなければ
      temp = Bitmap.new(size * 5, size) #ビットマップ作成
      temp.font.size = size             #フォントサイズ指定
      temp.draw_text(0, 0, size * 5, size, "0123456789")#数字画像セット描画
      @cache[size] = temp               #ハッシュに格納
    end
    @cache[size]                        #ビットマップを返す
  end
end

これに合わせてScoreview1クラスを書き換えます。
  def initialize(x, y, value, size = 22)
    @x = x                                   #x座標
    @y = y                                   #y座標
    @prevalue = value                        #値の変更を検知するための比較用変数
    @size = size                             #フォントサイズ
    @scores = Array.new                      #各桁用スプライトを置くための配列
    @numbers = Bitmap.new(@size * 50, @size) #数字画像セットのビットマップ作成
    @numbers.font.size = @size               #フォントサイズ指定
    @numbers.draw_text(0, 0, @size * 50, @size, "0123456789")#数字画像セット描画
    make_score(value)                        #作業の続きはmake_scoreで
  end
この部分を、
  def initialize(x, y, value, size = 22)
    @x = x                                   #x座標
    @y = y                                   #y座標
    @prevalue = value                        #値の変更を検知するための比較用変数
    @size = size                             #フォントサイズ
    @scores = Array.new                      #各桁用スプライトを置くための配列
    @numbers = CacheNumber::numberset(@size) #数字画像セットを取り出す
    make_score(value)                        #作業の続きはmake_scoreで
  end

こうするだけでOK。
速度も55〜65FPS程度に向上しました。


Viewport指定と画像の移動による書き換え

画像セットの見える範囲だけを操作すると言えばViewportの利用も考えられます。
ViewportはSpriteに指定することで画像の可視範囲を制限する効果があります。
しかしそれだけではなく、同じViewportを持ったスプライトのオブジェクトを
一斉に操作したりもできます。
なのでViewportは別に利用したいと言う場合にはこの方法は使えません。

前項の処理とほとんど共通なのでちゃちゃっと書いてしまいます。
#==============================================================================
# ■ Scoreview2
#------------------------------------------------------------------------------
#  数字表示クラス(Viewport指定と画像の移動による書き換え)
#==============================================================================

class Scoreview2
  #--------------------------------------------------------------------------
  # ● オブジェクト初期化
  #--------------------------------------------------------------------------
  def initialize(x, y, value, size = 22)
    @x = x                                   #x座標
    @y = y                                   #y座標
    @prevalue = value                        #値の変更を検知するための比較用変数
    @size = size                             #フォントサイズ
    @scores = Array.new                      #各桁用スプライトを置くための配列
    @viewports = Array.new                   #各桁用ビューポートを置くための配列
    @numbers = CacheNumber::numberset(@size) #数字画像セット
    make_score(value)                        #作業の続きはmake_scoreで
  end
  
  #--------------------------------------------------------------------------
  # ● 更新
  #--------------------------------------------------------------------------
  def move(value = @prevalue)
    if value != @prevalue                    #値が変わったら
      @prevalue = value                      #次回比較のため現在の値を指定
      make_score(value)                      #作業の続きはmake_scoreで
    end
  end
  
  #--------------------------------------------------------------------------
  # ● 数値画像作成
  #--------------------------------------------------------------------------
  def make_score(value)
    digit = 0                                 #桁数-1 兼 配列用ポインタ
    while value >= 0                          #数値が0以上なら
      digit1st = value % 10                   #1桁目を取り出す
      if @scores[digit] == nil                #この桁のスプライトがなかったら
        @viewports[digit] = Viewport.new(@x-digit*@size/2, @y, @size/2, @size)
                                              #ビューポート作成
        @scores[digit] = Sprite.new(@viewports[digit])#スプライト作成
        @scores[digit].bitmap = @numbers      #数字画像セットを置く
      end
      @scores[digit].x = -digit1st * @size / 2#数値に合わせて画像を移動
      value /= 10                            #1桁目を削る
      digit += 1                             #ポインタを進める
      if value == 0                          #数字がなくなったら
        while @scores[digit] != nil          #以前表示した画像の上位桁があるなら
          @scores[digit].x = 0               #0を表示してみる
          digit += 1                         #ポインタを進める
        end
        break                                #終了
      end
    end
  end
end

これで実行してみたところ大体60〜70FPSでした。
処理としては画像を移動させているだけなので、速度的に若干有利なようです。
しかしまあこのくらいの差ならほとんど大差無しと言うべきでしょうね。
Viewportの別の利用価値を考え合わせるとちょっと使いづらいでしょうか。


1桁の数字画像を取り替えることによる書き換え

これまでとは少し考え方を変えて、今度は0〜9の数字を個別の画像として用意し、
画像を取り替えることによって数値を変える方法を試してみます。
数字画像の形式が違うので、まずはキャッシュから作ります。
module CacheNumber
  #--------------------------------------------------------------------------
  # ● 個別数字画像の配列のキャッシュ
  #--------------------------------------------------------------------------
  def self.number(size = 22)
    unless @cache.include?(size*1000)        #ハッシュ内にキーが存在しなければ
      @cache[size*1000] = Array.new(10){|i|  #10個の配列を作成
        tmp = Bitmap.new(size/2, size)       #数字1桁分のビットマップを作成
        tmp.font.size = size                 #フォントサイズ指定
        tmp.draw_text(0, 0, size/2, size, "#{i}")#1桁の数字描画
        tmp                                  #配列に返す
      }
    end
    @cache[size*1000]                        #ビットマップ配列を返す
  end
end

前の2つで使用したキャッシュと被らないように、サイズを1000倍したものをキーとしました。
この方法しか使わない場合は不要です。
それからビットマップではなく配列で返ってくる点に注意しましょう。

ではクラスの方も書いてみます。
#==============================================================================
# ■ Scoreview3
#------------------------------------------------------------------------------
#  数字表示クラス(1桁の数字画像を取り替えることによる書き換え)
#==============================================================================

class Scoreview3
  #--------------------------------------------------------------------------
  # ● オブジェクト初期化
  #--------------------------------------------------------------------------
  def initialize(x, y, value, size = 22)
    @x = x                                   #x座標
    @y = y                                   #y座標
    @prevalue = value                        #値の変更を検知するための比較用変数
    @size = size                             #フォントサイズ
    @scores = Array.new                      #各桁用スプライトを置くための配列
    @numbers = CacheNumber::number(@size)    #数字画像セット
    make_score(value)                        #作業の続きはmake_scoreで
  end
  
  #--------------------------------------------------------------------------
  # ● 更新
  #--------------------------------------------------------------------------
  def move(value = @prevalue)
    if value != @prevalue                    #値が変わったら
      @prevalue = value                      #次回比較のため現在の値を指定
      make_score(value)                      #作業の続きはmake_scoreで
    end
  end

  #--------------------------------------------------------------------------
  # ● 数値画像作成
  #--------------------------------------------------------------------------
  def make_score(value)
    digit = 0                                 #桁数-1 兼 配列用ポインタ
    while value >= 0                          #数値が0以上なら
      digit1st = value % 10                   #1桁目を取り出す
      if @scores[digit] == nil                #この桁のスプライトがなかったら
        @scores[digit] = Sprite.new           #スプライト作成
        @scores[digit].x = @x - digit * @size / 2#x座標指定(桁の位置を考慮)
        @scores[digit].y = @y                 #y座標指定
      end
      @scores[digit].bitmap = @numbers[digit1st]#数字画像を表示
      value /= 10                            #1桁目を削る
      digit += 1                             #ポインタを進める
      if value == 0                          #数字がなくなったら
        while @scores[digit] != nil          #以前表示した画像の上位桁があるなら
          @scores[digit].bitmap = @numbers[0]#0を表示してみる
          digit += 1                         #ポインタを進める
        end
        break                                #終了
      end
    end
  end
end

これも大体60〜70FPSといったところです。
画像自体を取り替えるので少し遅くなるかと思ったのですが、まったく問題ないようです。


各描画方法の負荷比較

これまではFPSを表示してみてのおおよその値で書いてきましたが、
負荷の違いをより明らかにするために、ここではもう少し厳密に測定してみることにします。

★測定方法
・再起動後不要なソフトを全て終了し、RPGツクールXPのみ起動
・上記の通り10桁の乱数22個を1フレームごとに書き換えるスクリプトを使用
・テストプレーモードで起動してF2キーを押す
・60秒間放置(起動直後は不安定なため)
・1秒ごとに瞬間値を手作業で100個以上記録する(この間キーやマウスには一切触れない)
・100個目までの値で頻度集計

★評価方法
【中央値】
頻度の値を大きい方から数えても小さい方から数えても同じになる(つまり50個目の)FPS。
平均値とはちょっと違う。この値が大きいほど負荷が少ないと言える。

【90%レンジ】
頻度の値を下から数えて5個(5%)と上から数えて5個(5%)を取り除いた分のFPSの範囲。
余計なノイズを取り除く操作。中央値が大きくても90%レンジの下端が小さければ、
断続的に大きな負荷が加えられていることを意味する。

つまりは古典的な騒音測定法ですが…


★結果

【src_rectの利用】
FPS616263646566676869
頻度26979837184
【中央値】67
【90%レンジ】62〜68

【Viewportの利用】
FPS6364656667686970717273
頻度47187771619186
【中央値】70
【90%レンジ】64〜73

【1桁の数字画像の利用】
FPS626364656667686970717273747576
頻度126247879710815104
【中央値】71
【90%レンジ】64〜75

後者2つの方法が若干負荷が少ないようですね。
とはいえ同じ方法論でもちょっとした組み方の違いで変わりますし、
PC環境によっても変わってくるでしょう。
後者ほど数値のばらつきが大きいのも少し気になります。
また、それぞれの方法で負荷以外のメリット・デメリットもあると思いますので
状況によって使い分けができるかもしれません。

一応今回はここまで。
今後も新しい方法を見つけ次第追加していくつもりです。



● 文字列キャッシュ
数字画像のキャッシュ機能を文字列全般に適用しました。

module CacheString
  @cache = Hash.new                   #各サイズBitmapを保存するためのハッシュ
  @onedot = Bitmap.new(1, 1)
  #--------------------------------------------------------------------------
  # ● 個別画像の配列のキャッシュ
  #--------------------------------------------------------------------------
  def self.string(chr, size = 22)
    key = chr + "#{size}"              #文字列とサイズでキーを作成
    unless @cache.include?(key)        #ハッシュ内にキーが存在しなければ
      tmp = @onedot                    #1ドットビットマップ
      tmp.font.size = size             #フォントサイズ設定
      text_size = tmp.text_size(chr)   #テクストサイズ取得
      width = text_size.width          #幅
      height = text_size.height        #高さ
      tmp = Bitmap.new(width, height)  #取得したサイズのビットマップ生成
      tmp.font.size = size             #フォントサイズ再設定
      tmp.draw_text(0, 0, width, height, chr)#描画
      @cache[key] = tmp                #キャッシュへ
    end
    @cache[key]                        #ビットマップを返す
  end
end



一覧へ戻る
トップページへ戻る



更新日時:2006 1/7 20:00
管理人:すっぴぃ ◆ADGYPSYxII