DeSmuMEに3D表示抑制機能を足してみた
Download Windows binary & source code (desmume 0.9.8-gfx3dHack)
ゲーム中のキャラクタの画像だけとか、地形の画像だけキャプチャしたい時ってありますよね。他のエミュレータと同じくDeSmuMEにもレイヤー表示・非表示を切り替える機能はあるのですが、ゲームによっては前景から背景に至るまで同一レイヤーで3D描画していることがあり、この場合には前述の機能は役に立ちません。
じゃあ何かしら役に立つ機能をつけてみようというのが本日の趣旨です。
改造の概要
「3D描画を部分的に抑制したい」というのが今回求める機能です。
内部的には3Dデータは多数のポリゴンで構成されています。これを位置情報に基づいて、3Dエンジンが背面〜前面にかけて描画することで処理されます。表示を抑制したければ、一部のポリゴンの描画処理をあえて行わなければ良いのです。簡単ですね。
レイヤーと同様にポリゴンの表示・非表示機能をつければ良さそうですが、困ったことにポリゴンの数は軽く100を超えるくらいには多いです。個別切り替えでは使い勝手もよくなさそうなので、「前の方のポリゴンを非表示」「後ろの方のポリゴンを非表示」という形で対応することにしました。「前の方」「後ろの方」の度合いを切り替えできるようにすることで、抑制度合に柔軟性をもたせます。
コードと使い方
0.9.8 のソースコードとの差分を作成して、改造版を作成しました。ダウンロードリンクは記事の冒頭にあります。
新しくダイアログを設けるのは実装の手間がかかるので、表示の抑制はLuaスクリプトを通じて行う形にしました。
-- desmume 0.9.8-gfx3dHack sample script local gfx3dMin, gfx3dMax = 0.000000, 1.000000 function modifyVisibility() gui.text(0, 0, string.format("%f %f", gfx3dMin, gfx3dMax)) gui.setgfx3dvisibility(gfx3dMin, gfx3dMax) end local keys = { {}, {} } gui.register(function() keys[1] = input.get() if keys[1]["6"] then gfx3dMin = math.min(math.max(gfx3dMin - 0.001, 0.0), 1.0) end if keys[1]["7"] then gfx3dMin = math.min(math.max(gfx3dMin + 0.001, 0.0), 1.0) end if keys[1]["8"] then gfx3dMax = math.min(math.max(gfx3dMax - 0.001, 0.0), 1.0) end if keys[1]["9"] then gfx3dMax = math.min(math.max(gfx3dMax + 0.001, 0.0), 1.0) end modifyVisibility() keys[2] = keys[1] end) emu.registerexit(function() gui.setgfx3dvisibility(0.0, 1.0) end)
追加した関数は1つだけです。
function gui.setgfx3dvisibility(gfx3dStart, gfx3dEnd) -- gfx3dStart: 0.0 = no effect, 1.0 = hide all polygons -- gfx3dEnd: 0.0 = hide all polygons, 1.0 = no effect
3Dポリゴンの表示割合を設定します。最前面・最背面にあるポリゴンの一部を隠すことができるようになっていて、例えば 0.2, 0.9 と指定すると、全ポリゴンのうち、最背面にある 20% のポリゴンと、最前面にある 10% (100%-90%) のポリゴンは表示されなくなります。サンプルスクリプトでは、数字の 6, 7, 8, 9 キーを使ってこの表示割合を変更できるようにしてあります(スクリプト自体の利便性は高くないので、目的に応じて改良することができると思います)。
感想
万能ではないですが、良い感じに欲しい画像が得られるようになりました。
総ポリゴン数は細かい状況の違いによって簡単に変動するので、少しゲームを操作すると表示がちらついて、思うような表示結果が継続されないという利便性の問題があります。不便さはありますが、数値を細かく調整する、欲しいフレームを狙ってキャプチャするなど、工夫次第で目的は果たせるかなあと思います。
あとは背景色が自前で指定できるとか、描画のない部分はアルファチャンネル付きの透明ピクセルとして保存できるようにするとか、そんな対応があると背景の除去や合成がぐっとしやすくなると思うのですが、今日のところは放っておくことにします。誰か興味があれば改良してくれると喜びます。
それでは、良いリッピングライフをお楽しみください☆
追伸
背景色の操作ですが、Memory Viewer で Palette 0 を操作したら変更できました。15 bit color かな?
5000000h Engine A Standard BG Palette (512 bytes) 5000200h Engine A Standard OBJ Palette (512 bytes) 5000400h Engine B Standard BG Palette (512 bytes) 5000600h Engine B Standard OBJ Palette (512 bytes) 7000000h Engine A OAM (1024 bytes) 7000400h Engine B OAM (1024 bytes) http://nocash.emubase.de/gbatek.htm#dsmemorycontrolvram
Soft Rasterizer はピクセルずれを起こすのでオススメ出来ません。OpenGL でレンダリングしましょう。条件によっては選びたいエンジンが変わるかもしれませんが。
Lua-GD でのアルファチャンネルの扱い
Lua-GD のアルファチャンネルの扱いでよく失敗するのでまとめました。勘違いしていると、じつは画像が透明だったり、不透明だったり、半透明があるのに思い通りにブレンドされなかったりします。
内容は Lua-GD 2.0.33r2 に準拠しています。他の環境下での挙動は保証しません。
画像の新規作成 (フルカラー)
- フルカラー画像の作成には、
gd.createTrueColor(x, y)
を利用。 - 画像作成後は、全面不透明黒で塗られた状態(透明ではない)
- 画像作成後は、gdImage:alphaBlending(true) の状態。
- 画像作成後は、gdImage:saveAlpha(false) の状態。
- アルファチャンネルの範囲は 0〜127 であり、0〜255 ではないので注意。
- 透明なキャンバスを作成するには透明ピクセルで塗りつぶす必要がある。下記にフルカラー透過画像を作成するコードを示す。
-- create a blank truecolor image gd.createTrueColorBlank = function(x, y) local gdImage = gd.createTrueColor(x, y) if gdImage == nil then return nil end local colorTrans = gdImage:colorAllocateAlpha(255, 255, 255, 127) gdImage:alphaBlending(false) gdImage:filledRectangle(0, 0, gdImage:sizeX() - 1, gdImage:sizeY() - 1, colorTrans) gdImage:alphaBlending(true) gdImage:colorDeallocate(colorTrans) return gdImage end
余談ですが、gd.create(gdImage)
的な関数はありそうでないです。
画像の読込 (PNG)
- PNG画像の読込には、
gd.createFromPng(filename)
を利用。- ファイル内容を文字列をとして読み込んである場合は
gd.createFromPngStr(string)
を利用。
- ファイル内容を文字列をとして読み込んである場合は
- パレットかフルカラーかは入力ファイルに基づく(要注意!)
画像の保存 (PNG)
- PNG画像の保存には、
gdImage:png(filename)
,gdImage:pngEx(filename, compression_level)
を利用。 - 出力ファイルがパレットかフルカラーかは入力画像に基づく。
- アルファチャンネルの保存は
gdImage:saveAlpha(true)
を実行していないと行われない。
画像のコピー・貼り付け
パレットとフルカラー
フルカラーとパレットの混在時には注意を要する場合がある。
- 画像がフルカラーかどうか調べるには、下記のようなメソッドを要する。
gd.isTrueColor = function(gdImage) if gdImage == nil then return nil end local gdStr = gdImage:gdStr() if gdStr == nil then return nil end return (gdStr:byte(2) == 254) end
- パレットの「透過色」とフルカラーの「アルファ」は異なる概念である。それ故か下記の問題がある。
gd.copy()
で パレット→フルカラー のコピーを行うとき、gdImage:alphaBlending(boolean)
に関わらず透過ピクセルが透過コピーされてしまう。ただし、gd.copyResampled()
であればgdImage:alphaBlending(boolean)
を尊重したコピーが行われる。
- 上記を考慮すると、画像をフルカラーに変換する下記のコードが書ける。
-- return a converted image gd.convertToTrueColor = function(srcImage) if srcImage == nil then return nil end if gd.isTrueColor(srcImage) then return srcImage end local gdImage = gd.createTrueColor(srcImage:sizeX(), srcImage:sizeY()) if gdImage == nil then return nil end gdImage:alphaBlending(false) local colorTrans = gdImage:colorAllocateAlpha(255, 255, 255, 127) gdImage:filledRectangle(0, 0, gdImage:sizeX() - 1, gdImage:sizeY() - 1, colorTrans) gdImage:copyResampled(srcImage, 0, 0, 0, 0, gdImage:sizeX(), gdImage:sizeY(), gdImage:sizeX(), gdImage:sizeY()) gdImage:alphaBlending(true) return gdImage end
おまけ:画像の左右反転
- 拡大コピーで負数を指定
- 反転画像の作成は、新しいGDライブラリにはあるが、上記 Lua-GD にはない。ただし下記で代用できる(巨大画像では低速)
-- flip an image about the vertical axis gd.flipVertical = function(gdImage) if gdImage == nil then return nil end gdImage:alphaBlending(false) for x = 0, gdImage:sizeX() do for y = 0, math.floor(gdImage:sizeY()/2) - 1 do local c1, c2 = gdImage:getPixel(x, y), gdImage:getPixel(x, gdImage:sizeY()-1-y) gdImage:setPixel(x, y, c2) gdImage:setPixel(gdImage:sizeX()-1-x, y, c1) end end gdImage:alphaBlending(true) -- TODO: restore the previous value return gdImage end -- flip an image about the horizontal axis gd.flipHorizontal = function(gdImage) if gdImage == nil then return nil end gdImage:alphaBlending(false) for y = 0, gdImage:sizeY() do for x = 0, math.floor(gdImage:sizeX()/2) - 1 do local c1, c2 = gdImage:getPixel(x, y), gdImage:getPixel(gdImage:sizeX()-1-x, y) gdImage:setPixel(x, y, c2) gdImage:setPixel(gdImage:sizeX()-1-x, y, c1) end end gdImage:alphaBlending(true) -- TODO: restore the previous value return gdImage end -- applies vertical and horizontal flip gd.flipBoth = function(gdImage) -- use of gd.copyRotated() can provide the same result? gd.flipVertical(gdImage) gd.flipHorizontal(gdImage) return gdImage end
おまけ:EmuLua
なぜTASはdesyncする?原因追求とデバッグの仕方
エミュレータのバグによるdesyncの傾向と対策。直接直したい人や、開発者に効果的なデバッグ依頼をしたいときに。
なお、TAS向けエミュレータで満たすべき要件は TASVideos / Laws Of TAS にまとめられています。
原因:矛盾したステートのセーブ・ロード
矛盾したステートのセーブ・ロードをするとdesyncします。例えば、AAAAと入力されたムービーに対して、AABBと入力されたステートをロードして、続きを作ってしまった場合などです。
ここではそういう、ある種ヒューマンエラー的なdesyncはさておいて、矛盾なく扱ったはずなのにdesyncする場合に的を絞ります。
根気でdesyncを乗り切るのは体力の無駄です。公式の Snes9x 1.51 でムービーを作成していた時期がありましたが、はっきり言って無駄な戦いに時間を使いました。まずエミュレータを直すべきです。
原因:ステートに保存すべき変数が保存されていない
ステートに保存すべき変数が保存されていなくてdesyncする場合です。ステートというのはハードウェアの状態(メモリの値、動作をエミュレートするためのカウンタ変数など)をすべてファイルに格納したものです。格納内容が足りていないとロード時に状態が元に戻りませんが、中には「何か足りていなくても、プレイする分には全然支障がなくて気づかない」ことがあります。TASは動きが細かいので、そういう細かいものがdesyncとして現れてきたりします。
desyncが起きる例(及び内部の動き)
- ムービー再生中の適当なタイミングで、ステート1を作成(このときエミュレータ内部の count という変数が count = 1 であるとする)
- 適当に1000フレーム動作させる(count = 2 になったとする)
- ステート1をロードする(count = 1 に戻って欲しいが、ステートに count が含まれていないと count = 2 になってしまう)
- そのまま動かしていくと、desyncする
どう報告する?(デバッグ材料の用意)
材料を用意しましょう。まず、適当なムービーを用意しておきます。TASのように「少し動きが変わると破綻しやすい」ものほど良いです。
- エミュレータを起動して(起動から始めること!)ムービーを再生します。ムービー再生時に開くROMを指定できればベストです。0フレーム目でポーズするなど、事前に極力余計なエミュレーションを行わないようにしてください。
- 再生中にいくつかステートを作成する(多いほど良い、動きが緻密でずれが禁物な箇所ほど良い、再生中のロードは厳禁)
- ステート作成後、適当な場所で適当なステートをロード→再生させて上記の最初の再生結果とずれがないか確認 を繰り返す。
- ずれを確認できたら、フレームカウンタを見つつ「ムービーをNフレームまで再生後、ステートAをロードするとずれる」というようにどのタイミングでロードすると起きるかを厳密に調べる。
- 開発者にムービーとステートAを渡して、「ただ再生した場合と、Mフレーム時点(ステートA作成時点)でステートセーブしておいて、その後Nフレーム目まで再生してステートをロードしたときで再生結果が違う」と伝える。「再生結果が違う」では曖昧なので「Xフレームあたりでキャラクターの攻撃が当たるはずが外れる」など、ゲームの内容に疎くてもわかるような表現で具体的に伝えると良い。
だいたいこのくらいできれば材料になると思います。再現「手順」を明確にすることが大切です。
いつのバージョンから起きる?
過去のバージョンでは問題がなかったのに、今のバージョンでは問題が起きているという場合、まずいつのバージョンから起きているかを明確にするとよいです。開発者の方は、SVNやGitなどのどのコミットが原因なのか、古いバージョンを受信・コンパイルしつつ二分探索的に探していきましょう。開発者でない方は開発途中の細かいバージョンの特定がしづらいかもしれませんが、SkyDrive - Orleans / Emulation などから過去の開発時ビルドを探してテストすると、範囲を絞り込むことができます。
どうデバッグする?
まずは寄せられた材料で状況が再現できることを確認しましょう。
大抵のエミュレータでは、主要な構造体がいくつかあって(CPU、PPU、SPU 等)その中にエミュレーションに関係する変数があります。時々そうではなく、何気なく使われているグローバル変数が重要なこともあります。手順としては、
- 主要な構造体、怪しげなグローバル変数をウォッチに追加する
- ステートセーブ後の位置にブレークポイント設置、ムービーを頭から再生して、Mフレーム時点でポーズしてステートセーブ、ウォッチ内容をコピペするなどして控える
- ステートロード後の位置にブレークポイント設置、Nフレームまで再生してステートロード、ウォッチ内容をコピペするなどして控える
- 2つのウォッチ内容を比較して差異のある箇所に着目する
こういう感じでしょうか。簡単にわかることもわからないこともあります。怪しい変数があればステートに変数を追加してみて、同じ手順を行なってみましょう。ずれがなくなれば修正成功です。
メモリの値で追えないならトレースログをとって比較するのも手段としてありそうです。HDD容量と実行速度がネックですが……。
原因:ステートロード時の誤った変数初期化
上記の亜種です。ステートに保存しないといけないのに、0に初期化している場合などです。
desyncが起きる例(及び内部の動き)
- ムービー再生中の適当なタイミングで、ステート1を作成(このとき count = 1 であるとする)
- セーブ直後に何もせずにステート1をロードする(ロード時の不当な初期化で count = 0 になる)
- そのまま動かしていくと、desyncする
同じ場所でセーブ・ロードしてもずれるのが特徴です。
どう報告する?(デバッグ材料の用意)
「ステートに保存すべき変数が保存されていない」ケースと同じで構いません。ただ、「その場でセーブロード」しても起きるのがこのケースの特徴なので、「Nフレームまで再生後、その場でセーブ・ロードして再生継続するとずれる」という形で報告可能であれば、そのほうが良いです。
どうデバッグする?
「ステートに保存すべき変数が保存されていない」ケースと同様です。
原因:リセット(ROM読込)時に初期化されない変数がある
これが結構厄介です。
desyncが起きる例(及び内部の動き)
適当なムービーを用意しておく。
- エミュレータを起動して(起動するところから始めること!)、「ステートに保存すべき変数が保存されていない」ケースと同様にムービー再生を開始します。(例:count = 0 で再生開始)
- エミュレータを起動して(起動するところから始めること!)、ROMを開いて、フレームカウンタがXフレームになったらポーズしてムービーを再生します(例:ROM起動後ちょっと動作させたことで count = 50 になっている、count = 0 にして再生開始して欲しいが、count = 50 を初期値としてムービー再生が始まる)
- 1回目と2回目で再生結果が異なる
特徴は、一切のステート操作を介さずにdesyncする点です。
どう報告する?(デバッグ材料の用意)
「例」のような手順が再現できたら、ムービーを渡してその手順をそのまま伝えてください。
その他
- 直面したことはないですが、マルチスレッド環境特有の desync 事例もあったとかなかったとか
- 開発者向けの再現手順を作ろうとしてもうまく作れないこともあると思います。そういうのはなかなか直せなくて厄介です。「ムービー」と「変なステート」だけ送っても材料にはなると思うので、一応送ってみてください。Luaスクリプトを利用して操作を自動化していた場合、それが思わぬ影響を及ぼしている可能性もあるので、スクリプトを使っていた旨を伝えたり、そのスクリプトを渡したりするとよいと思います。期待は禁物。
- 過去のバージョンでは起きないが、いつのバージョンから起きるという情報も有用?
- みんなでがんばって desync をたおしましょう
ポイント
- とにかくムービーファイルを送ってあげてください! 開発者はdesync確認に適したムービーファイルなんて持っていません。作成には手間がかかりますし、作成しても再現できない可能性も高まります。その他、ステートファイルとか関係するものは一式送ってあげてください。あればなんとかなるとも言えませんが、ないよりは喜ばれます。
- できるだけ確実でシンプルな再現手順を教えてあげてください! 異なる「再生結果1」と「再生結果2」を再現する手順です。ステート作成は開発者側で行わせるような手順で。
- 「ずれちゃう」ことを伝えるときは、「何フレーム目から○○の動きが変わる」などを、なるべくゲームを知らなくても理解できる表現で伝えるとよいです。
近鉄特急のネット予約とか買い方について調べたよ
突然ですが、よくわからなかったので駅員さんに聞いたりしていろいろ調べました。
間違いや変更がある可能性があるので、確実な情報は信頼できる情報源を当たってください。
基礎知識
- 購入形態によらず乗車券と特急券は別である。ネット予約・購入できるのは特急券であり、乗車券は別に購入する必要がある(当日券売機で普通に買う、事前に金券ショップで株主優待切符を買うなど)。
- アーバンライナーなど上級の車両を利用する場合には加えて特別車両料金がかかるが、詳細は割愛(よく知らない)。
- 特急券は当日に窓口や駅ホームで購入可能。ただし乗車直前だと満席の場合に苦労するため、事前購入を行っておけばその点で安心である。事前購入の手段としてインターネット予約・購入がある。本記事ではこれについて述べる。
- 特急車両は指定席制であり、新幹線の指定席車両のように予約時に座席が決定されるシステム。
- インターネット予約・購入の形態は3つある。ここでは主に「チケットレス」「購入」「予約」と称する。
- 窓口で購入する場合も、インターネットを通じて購入する場合も価格は同一だが、後者はポイントがつくため頻繁に特急券利用がある場合はお得。
私的結論
3種の購入形態
いずれの場合も近鉄 インターネット予約・発売サービスから会員登録した上で操作します。
以下、近鉄でいただいたパンフレットに記載されている内容です。
購入(チケットレス) | 購入(駅でお受け取り) | 予約(駅でお支払い・お受け取り) | |
---|---|---|---|
クレジットカード・積立金カードで購入 | ○ | ○ | × |
現金・特急カード・企画券などでお支払い | × | × | ○ |
受取り期限 | 受取り不要 | 特急列車発車直前まで | 予約日を含め8日以内かつご乗車前日まで |
netポイント加算 | ご購入金額の10% | ご購入金額の5% | × |
おわかりいただけたでしょうか。個人的に重大だったポイントを下記に挙げます。
なお、わたしはチケットレス以外での購入はしたことがありません。他の購入方法に関しては画面だけ参照しました。
チケットレス
- クレジットカードあるいは積立金カードで購入する。
- 予約時に「シートマップ」画面で車両の予約状況を閲覧の上、好きな「座席」を予約できる。
- 窓口等での発券は不要。代わりに購入した列車・座席情報を携帯電話の画面に表示するか、もしくはパソコンから印刷した内容を提示する。また決済に利用したカードを所持すること。
- なお「大人3名」のように購入すると「大人3名」のチケット風画像1枚が画面表示される。画像3つではない。
- 3回まで列車変更できる(他のプランでどうであるかは不明)
- 購入金額の10%のポイントが加算される。
購入(駅でお受け取り)
予約(駅でお支払い・お受け取り)
付記
- カード決済した場合、そのカードを持参するように書かれています。ただ決済自体は済んでいるので、もしカードを持参していなくてもさほど問題にはならないのではないでしょうか?(検証したわけではない)
- 「ごめんなさい。パパが予約してくれたから、カードは持ってないんです……ううっ(うる目)」とあざとイエローっぽく言えば、他の人に購入してもらった場合でも乗り切れると信じています(責任は負いかねます) とくにチケットレス購入の場合、印刷物の提示を行いますしそれで十分かなと思うのですが、どうなのでしょう?
- 特急券を駅で当日購入することに関連して特急車両の混雑度を聞いたところ、ゴールデンウィークはそれなりに混雑するので、当日・直前だとどうなるかはわからないそうです。普通の土日はイベントの有無などに応じて混雑具合は異なるため、タイミングによっては同様に困難なこともあるそうです。
- 特急券と関係ありませんが、近鉄株主優待切符はゴールデンウィーク中でも利用可能だそうです。
- 特急券を利用しないのは、明快かつ経済的なソリューションのひとつです。 ;)