DXRuby Advent Calendar 2014 : DXRubyの怪しげで不自然な挙動について語る

この記事はDXRuby Advent Calendar 2014の15日目です。14日目の記事はあおいたくさんの「ゲーム用GUIフレームワーク作った(作ったとは言ってない)」でした。どっちやねん。

俺もDXRubyWSというウィンドウシステムを作ってはいるのだが、ゲーム用UIにも使えるという初期の構想は開発当初に道を踏み外して以来戻ることなく全く違う方向に進んでしまっていて、コンパクトでシンプルなものがあるといいなーと思っていた。
コードを覗いてみるとSpriteUI::MouseクラスがSpriteを継承していてフレームワークのマウスオブジェクトが自身の衝突判定を持っているとか、SpriteUI::Baseクラスの描画処理に「(target or Window).draw(x, y, image)」などと書かれていたりするし、クラスやモジュールの分け方や使い方なども繊細で、RubyにもDXRubyにも精通したテクニカルかつスマートな感じになっている。非常に興味深いので、この手のものに興味がある人も無い人も見てみるとよいだろう。
今回のDXRubyAdventCalendar2014は書いたコードを公開する人の割合が多いような気がする。人のコードは勉強になるし、俺にとってもどのように使われているのかを知る貴重な情報源でもあるので、大変よいことである。と言ってもコードを出してない記事がダメって言ってるわけではない。何かしらの情報発信はよいことだし、コードの出しようがないネタもある。でもまあ、実際に動くゲームを作ってそのコードを公開することほど情報量が多いものは無いかな。って俺もそんなん出したことないわー。

さて、今回の記事は題して「DXRubyの怪しげで不自然な挙動について語る」。DXRubyを作って自分で使っていて、あれ?って思うことは意外によくあるのだが、それをなんとかしようとしてもどうにもできないということも意外によくある。この記事はそのような微妙な動作、仕様について取り上げ、一つ一つ言い訳しt、じゃなくて、原因と理由を述べてみようという趣旨である。わかってるんだけど直せないんだよね〜

RenderTargetを作ってからWindow.loopに入ると画像が壊れる

\たぶんビデオドライバの仕様です!/

以下のようなコードを実行するとRenderTargetのデータが壊れてフルカラーのノイズが描画される。

require 'dxruby'

Window.width, Window.height = 800, 600
rt = RenderTarget.new(640, 480, C_WHITE)

Window.loop do
  Window.draw(0, 0, rt)
end

はずだったのだが、1年ぶりに試したら正常に動いた。前に実験したときはWindowsXPだったしビデオカードも使ってたのでそのへんが原因だったのかもしれない。GPUのメーカーと世代、ドライバのバージョンも影響しそうだ。なんにせよ、おそらくこれで画像が壊れるPCはまだ世の中に存在するだろうと思われる。ポイントはRenderTargetに画像を作った状態でウィンドウのサイズ変更、である。
想定される理由としては、フレームバッファを確保して、その後ろにRenderTargetの領域を確保して白で初期化したが、ウィンドウのサイズを変えてWindow.loopを実行した際にフレームバッファのサイズが変更されて、RenderTargetの領域が後ろにズレて未初期化の領域を参照した、ってところか。前にも書いたがRenderTargetはビデオメモリにデータを持つので何かと不安定だ。
ちなみにこのコードでRenderTargetの中身が消えないのはRenderTargetに対して何も描画してないからで、描画ネタに使ったからと言ってRenderTargetに何も描画していなければ自動updateは動かないのでクリアもされない。デバイスロストを考えるとこのような使い方はよろしくないが、Windows7になったらデバイスロストを起こそうとしてもなかなか起きてくれないので、このへんの仕様はまた考え直すべきなのかどうなのか。リスクのが大きいか。どうだろう。

Window.loopを抜けてもウィンドウが閉じない

\バグですがいずれ仕様になります!/

Window.loopには一応ウィンドウを閉じるコードは入ってはいるのだが、そのコードが動くのはユーザ操作でウィンドウを閉じたときだけで、breakで抜けた場合には動かない。そもそもRubyはbreakするとたぶんlongjumpでWindow.loopから直接抜けてしまうので、ensureでひっかけるとかしないと閉じることができないのではないかと思う。
これはおそらく一番初めから存在していたバグで、長いこと気づかなかった理由は、普通はWindow.loopをbreakで抜けたらプログラムが終わってウィンドウが閉じられてしまうからで、後処理に長い時間がかかるようなコードを書いたことが無いからである。
ちなみに次の1.4.2ではWindow.loopを複数置くために勝手に閉じられては困るので、これが正しい動きになる予定。

縮小のフィルタが綺麗に描画できない

\理屈どおりです!/

require 'dxruby'

image1 = Image.new(100, 100, C_BLACK)
25.times{|y|image1.line(0, y*4, 99, y*4, C_WHITE)}
25.times{|x|image1.line(x*4, 0, x*4, 99, C_WHITE)}

image2 = Image.new(100, 100, C_BLACK)
50.times{|y|image2.line(0, y*2, 99, y*2, C_WHITE)}
50.times{|x|image2.line(x*2, 0, x*2, 99, C_WHITE)}

Window.loop do
  Window.draw(20,20,image1)
  Window.draw_scale(130,20,image1, 0.5, 0.5)
  Window.draw_scale(190,20,image1, 0.25, 0.25)
  Window.draw(20,140,image2)
  Window.draw_scale(130,140,image2, 0.5, 0.5)
  Window.draw_scale(190,140,image2, 0.25, 0.25)
end


画像を2つ用意して、それぞれ1/2、1/4に縮小描画してみた。1/2まではそれっぽく描画されているが、1/4の画像がどっちも同じになっているのはわってもらえるだろうか。
GPUはテクスチャの色を取得する際に、テクスチャ上の座標を0.0〜1.0で表現し、その位置の色を取得する。座標の表現方法がピクセル単位じゃないので、基本的には近い場所の色をブレンドした結果を得る。通常は線形補間である。従って2*2の4ピクセルブレンドまでが限界で、理想的に縮小できるのは1/2までとなり、それを超えると間のピクセルを飛ばす間引きが発生する。さっきの画像の例で言うと、上の絵の1/4版では黒い部分が間引きされて白黒半々のブレンドになってしまった、ということだ。
理想どおりの縮小をするためには、どんなに小さく描画しても全ピクセルを走査して合成する処理が必要になるので、ハードウェアでやってくれるのはイマイチ期待できない。いまんところ、1/2、1/4・・・といったサイズのテクスチャをあらかじめ用意しておくミップマップというテクニックで回避することはできるのだが、DXRubyがそれに対応していない(DirecXにはある)。現状はインターフェイスをどうするかで悩んでいるところ。

Window.draw_fontでイタリックすると右が欠ける

DirectXの仕様です!/

require 'dxruby'

font1 = Font.new(32, nil, weight:true, italic:true)
font2 = Font.new(32, nil, weight:true)
Window.loop do
  Window.draw_font(0, 0, "かきくけこ", font1)
  Window.draw_font(0, 32, "かきくけこ", font2)
end


Window.draw_fontはDirectXのD3DXのD3DXSprtiteとD3DXFontを使って描画するため、楽ではあるのだが、仕様的な動作に対してはこちらではどうすることもできない。おそらく内部的に文字ごとに画像を作ってテクスチャ化して描画しているのだと思うのだが、Font#get_widthと同様にイタリック体で傾いたぶん増加した幅が取得できておらずテクスチャサイズが足りていない。
こういった現象を回避するためにはWindow.draw_font_exのように自前でテクスチャを作って画像生成する必要があるが、それをすると今度は改行などが反映されなくなるので、そのへんを作り込まないと互換性が保てなくなってしまう。いっそのこと改行処理とかを作って文字描画系すべてにその処理を入れてしまうのがいいのかもしれない。大変そうだけど。
ちなみにWindow.draw_font_exでなぜ欠けないかと言うと、半角空白1個分だけテクスチャを余分に大きくしているからである。

Window.scale=で倍率変えるとマウス座標がズレる

\仕様です!/

Window.scale=はWindowsAPIで倍率を変える機能で、DirectXではない。このメソッドの引数にはFloat型を渡すことができるので整数倍である必要も無いのだが、ともあれこれで倍率を変えたときにInput.mouse_pos_x/yの戻り値がこの倍率変更に追随しない。
この仕様は単純に考慮漏れのようにも見えるが、実際のところ、例えば1.5倍された場合に(元データでの)2つのピクセルが(画面上の)1ピクセルブレンドされた状態が発生し、ここにカーソルを当てたら座標はどっちが返ればいいの?という疑問があり、まさかFloatで返すわけにもいかず、まあ、自分で計算してくれ、ということでこのような仕様になった。
あ、マニュアルに書いてないのが問題なんだなこれ。

なんでキーとか定数なの?

\楽だからです!/

他の似たようなライブラリではキーを定数じゃなくシンボルで指定することが多いようだ。でもDXRubyは定数である。これはなぜかと言うと、Rubyの定数にDirectXの定数をFixnum化したものをそのまま突っ込んでいるからで、そうすると、Rubyから受け取ったFixnumをintに変換してそのままDirectXの関数に渡すことができて、作るのが非常に楽になるのである。シンボルだと何かと面倒で。
昔、pngファイルが出力できなかった頃に「Image#saveのフォーマットを指定する引数に3を渡すとpngになります」てな話をした事があったのだが、これはDirectXのフォーマット定数でpngを表すものが3と定義されているからである。

このときはDirectXのヘッダとかを調べず脳裏に「3」って浮かんだのでその場でそのように伝えたのだが、いつ調べたのかも思い出せないような定義をよく覚えてたな〜と一人で感心した次第。まったくもってどうでもいい。
そうそう、定数の定義は基本的にCの定数だから、中身を見ると数字が入ってることが多い。例えばキー定数などはDirectInputを使っているので0〜255ということになっていて、(0..255).to_a - Input.keysなどとすれば押されていないキーの一覧が取得できたりする。しかしこれはいささか内部仕様の利用的な感じになるのでオススメできない。定義されているキーの一覧を取得するメソッドなどがあるといいのかな。

Window.draw_circleがないのはなんで?

\作るのが面倒だからです!/

三角形の集合であるポリゴンで円を描くのは大変である。2Dライブラリなのだからテクスチャ生成して描画する手もあるのだが、draw_circleするたびにテクスチャ生成するのもなあー。円を描くだけでも時間かかるしめんどいのに。
ちなみに文字描画も自前/DirectX内部処理共にテクスチャを生成してそこに文字画像を転送して板ポリゴンで描画しているので非常に遅い。遅い原因は文字画像を作るところもあるけども、テクスチャを生成→画像転送→解放ってしてるところも大きい。
それはそれとしてこのあいだ思ったのだが、円描画用のShaderを内部で生成しておけば円描画も高速にできるんじゃないか。需要のほどはわからんけど気が向いたらやってみるかも。

Sprite.drawの動き。Spriteじゃないものはvisible=falseでも呼ばれるのはなんで?

\仕様です!/

Sprite.drawは渡した配列の中身に対してdrawメソッドを呼ぶ、という機能だが、例えばvanished?に対してtrueを返してきた場合はdrawを呼ばない。ところがこのようなチェックをしているのはvanished?だけでvisibleは見ていない。この点で、Spriteじゃない独自クラスのオブジェクトを配列に混ぜた場合に「あれ?」ってなることがある。残念なことに俺もなったことがある。
vanished?にだけ反応する理由は、Spriteクラス全体としてvanishedなデータはアクセスしないという決まりがあるからで、Spriteじゃなくてもvanished?にtrueを返す場合は何もしないべきだからである。なので、Sprite.drawでもSprite.updateでもvanished?に対してtrueを返してきたオブジェクトには何もしないし、Sprite.cleanは配列から駆逐する。
visibleなどを見ないのは、その属性を見て処理をどうこうするのは各オブジェクトのdrawメソッドの役割だからである。Spriteもvisible=falseだったとしてもdrawは呼ばれていて、その内部でやっぱり描画しない、という処理をしている。visible属性に対してそれをどう処理するかはその属性を持ったオブジェクトの仕様によるので、Spriteのクラスメソッド側で判定してdrawを呼ぶかどうかを決めるということは無い。
このへんの仕様はバージョンごとに二転三転していたが、とりあえず現時点の動作でひとまず決定でよいだろう。今は、Sprite.update/drawについては
・vanished?があれば呼んでみてtrueが返ってきたら放置
・update/drawがあれば呼ぶ
という形になっている。
Sprite.cleanは
・vanished?があれば呼んでみてtrueが返ってきたら配列から削除
である。

おしまい

書いてみて思ったのだけども、この記事の情報の有用性っていかほどのもんなのかしらん?もしかして:ほぼゼロ

ということで、DXRuby Advent Calendar 2014の15日目の記事、「DXRubyの怪しげで不自然な挙動について語る」でした。明日、16日目はGameKazuさんの「DXRubyでRPGを作りたくて」です。お楽しみに〜