LV. 19
GP 141

【心得】【RMVA】使用Ruby-C DLL結合RGSS3改善遊戲效能 [長文+新手慎入]

樓主 Compeador ken1882
GP19 BP-
貼心小提醒:
本文不適合剛接觸RMVA的新手閱讀, 如果沒有運算效能問題的話其實也可以不用搞到那麼底層啦
此外, 建議擁有以下背景知識以方便順利閱讀:
- (必備) 撰寫RGSS腳本的經驗
- (強烈建議) 基礎C/C++知識
- (建議擁有) 熟悉Ruby和C/C++相關語法
- (可有可無): PE(portable executable) 和 DLL(動態連結程式庫)的基礎知識

------------------以下廢話------------------
會有這個邪念主要是因為最近在自幹的遊戲系統越來越大了...然後禎數也不EY的越來越低了, 於是想到Ruby和Python一樣都是由C寫成的Interpreter Language, 網路上有不少大大靠直接在C跑東西來加速...所以我就想要來搞RMVA了XD
雖然中間崩潰好幾次啦, 不過我覺得成效還算不錯而且也很有分享價值(相信我, 你絕對不會想要從頭開始慢慢摸), 所以就來發個心得文(?)

本文主要分為以下幾個部分:
0. 基礎概念
1. 環境架設與Demo
2. 建置DLL
3. 資料如何在C和RGSS互相存取
4. 如何從RGSS301.dll中把要用到的函式挖出來  <= 涉及反向工程, 不在此文論述(我是用Cheat Engine+x64bdg+Ruby1.9.2-p0原始碼把最需要用到的東西挖出來的)
------------------ ~ 底下正文 ~ ------------------
[基礎概念]
大家應該都知道寫Ruby的時候不用特別去指定他的資料型態, 還可以非常自由的變來變去. 所有的Ruby物件的資料型態在C當中都叫做"VALUE" -- 不過就是一個class pointer啦,除了像Fixnum, Symbol, True/False......這些簡單基礎的類別是直接對他的數值進行"特殊處理"; 以Fixnum為例, 看一下在ruby.h的原始碼就知道了:
#define INT2FIX(i) ((VALUE)(((SIGNED_VALUE)(i))<<1 | FIXNUM_FLAG))
其中SIGNED_VALUE通常就是LONG(絕大多數跟int相同) 然後FIXNUM_FLAG 就是 1, 白話來說就是 (n * 2 + 1)...所以Ruby只要靠這樣就知道這傢伙只是單純數字而不是什麼其他亂七八糟的東西。如果有用過Cheat Engine的話這樣就知道為什麼大部分數值的"加密"是乘2加1了。
但如果是字串,浮點數和其他上面沒提到的物件的VALUE就是指標(pointer)了, 不過要取得他們的記憶體位置也不難, 只需要:
obj.object_id << 1 # 物件ID乘2
這樣我們可以將物件的記憶體位置傳到我們的DLL裡面, 並且由C取得他們的位置和數值了

在撰寫過程中還須注意到幾個點:
- 盡量避免存取Ruby資料(VALUE), 因為很慢
- 不要把需要大量存取Ruby資料的功能也在DLL裡面, 因為會反而更慢
- 綜合以上兩點, 需要大量計算的功能才適合寫在DLL裡面
- 因為Ruby儲存整數(Fixnum)比浮點數(Float)更簡單, 所以盡量使用整數, 並且在DLL中把Fixnum轉為int儲存, 運算結束後再將結果轉回VALUE
- 有些人很愛用std::vector(94我), 但除非陣列需要大量插入刪除, 不然應使用固定長度陣列為佳(int array[len] 或 int* array = new int[len])

[環境架設]
- 既然都是Ruby了, 建議下載[Ruby1.9.2-p0的原始碼], 這也是RGSS3所用的Ruby版本
- 編譯C/C++的IDE, 可用Visual Studio, 不過VS是出名的難用所以我是用尻巴辣Code::Blocks
- (可有可無) Debugger, 我是用Visual Studio的JIT除錯(VS少數可取之處), 萬一遊戲在跑DLL時候出錯的話他可是不會跟你說哪裡有病而是直接躺平

當環境架好並把DLL專案開好之後, 下一個步驟就是要告訴編譯器Ruby那些東東是什麼鬼, 注意我們不必將整個Ruby原始碼 include 進來(雖然我就這樣幹了甚至還TMD花了一整禮拜把Ruby本身編譯起來),只需要將必要的define include 進來即可. 如果懶得自己搞可以[點這裡] 下載我DLL專案內打包的Ruby標頭檔。
相關必要的function則是需要呼叫RGSS301.dll裡面的東西, 不是自己叫Ruby原始碼裡的東西, 如果叫了...就恭喜你獲得一個"ruby_vm_current_ptr is nullptr" 的debugger訊息然後遊戲通通當掉。
完成後, 標頭檔案應該會有:
#include <windows.h>
#include "ruby.h"
[Demo]
在開始講解接下來的東西之前, 我想應該可以先秀一下demo的東西和實驗結果
Demo主要是在比較RGSS和DLL裡面做人物和projectile(拍謝, 想不到適合的中文翻譯)碰撞偵測效能的差異, 而實驗測量的方法滿簡單的, 設置一個保證會一直update的事件由A點跑到B點, 然後跑10次看平均花了幾秒, 由於LAG會造成遊戲變慢, 所以秒數越少的代表越順暢。此外, 我有加了Theo's Anti-Lag(以下簡稱ATL)來改善一下RMVA一直被詬病的圖像處理。

實驗資訊:
> 演算法: AABB碰撞偵測, 平均時間複雜度為 O(n^2), 存取Ruby物件為 O(n) <= 因為慢所以要特別算
> A到B點的距離: 10格 (定義在demo地圖裡最左上角的事件裡)
> 測量用事件: 4倍快, 最高頻率
> 每禎產生5個Projectiles
> 最高可視Projectile Sprites數量: 100/1000000000 (其實最多也才近700個, 所以後者可以當作無限多; 超過的不會顯示但仍然會計算碰撞)
> 總共地圖事件量: 277, 需要檢測碰撞的事件共有65個(不含玩家及夥伴)
※結果將會依照電腦性能不同而有所不同, 我是在I5-6200U/4G記憶體的筆電上測的

實驗結果:
無需碰撞偵測: 平均1.0149 (秒)

可視Sprite限制為100, 不用DLL, 無ATL:
平均5.4282, 最高的Projectile數量 n = 620, lim => 5.98 (在最高數量下跑完一次約需要5.98秒)

可視Sprite限制為100, 只用DLL: 平均3.1801, n = 620, lim => 3.38
限制為100, 只用ATL: 平均3.3929, n = 676, lim => 3.92
限制為100, DLL+ATL: 平均1.3161, n = 676, lim => 1.39
限制為10億, 只用DLL: 平均4.3676, n = 650, lim => 5.11
限制為10億, 只用ATL: 平均4.0854, n = 693, lim => 4.92
限制為10億, DLL+ATL: 平均2.3111, n = 693, lim => 3.09

附上圖片的結果:
由於巴哈沒有Spoiler摺疊功能所以放圖很吃版面(也吃流量), 所以想看請到我在RM官方論壇的po文搜尋 spoiler: Result (with images)

接下來的部分搭上程式碼比較好理解, 所以先放上載點:
如果跑Demo很當的話看一下F11=>Config腳本裡面的設定, 並自己改好參數再測看看
------------------ ~ 我是分隔線 ~ ------------------
[建置DLL]
一切的一切...都要從標頭檔開始(???)
在我的範例中, 我使用的副檔名為.hpp而非.h,那主要的原因是.hpp可以讓你寫宣告兼implementation,其中RGSS301.dll的東西都會需要在runtime初始化所以需要寫implementation.
[main.hpp]
裡面有一些常用的library:
#include <windows.h> // 沒這個幹不了DLL
#include "ruby.h"         // 告訴編譯器Ruby的那些東東
#include <iostream>  // C++,反正就是C++
#include <cmath>     // 數學相關函式,如三角函數
#include <algorithm> // 方便好用的內建演算法,像是std::sort
#include <vector>    // 文章開頭有提到了,在此不多做解釋
#include <map>       // 把它當Ruby裡面的Hash用,只不過key的資料型態都要一樣,value也是.(實際上是紅黑樹)
#include <queue>     // 解圖論相關問題必備工具,像是最短路徑問題
#include <stack>     // 把陣列反轉的時候很好用
#include <string>    // 一些常用的字串轉換函式
#include <cstring>   // 常用的字串處理函式庫
#include <sstream>   // 好用的字串處理工具
如果想知道他們詳細有什麼東西可以用到 http://www.cplusplus.com/reference/

接著我們要來宣告需要從RGSS301.dll用到的函式原型(實際上這些在ruby原始碼裡面都有)
typedef VALUE(*rgss_obj_ivar_get_proto)(VALUE, VALUE);
typedef VALUE(*rgss_obj_ivar_set_proto)(VALUE, VALUE, VALUE);
typedef VALUE(*rgss_ary_at_proto)(VALUE, VALUE);
typedef VALUE(*rgss_ascii_new_cstr_proto)(const char*);
如果不清楚這部分語法的話, 拿typedef VALUE(*rgss_obj_ivar_get_proto)(VALUE, VALUE);當作例子解釋比較快:
VALUE(*rgss_obj_ivar_get_proto) - 函式回傳資料型態和該函式指標的資料名稱(typename)
(VALUE, VALUE) - 函式會用到的參數資料型態, 在這裡就是兩個VALUE

到這邊應該可以看出Ruby和一般平常C的差異在哪裡了, 雖然都是用C寫的, 但Ruby的資料型態都是VALUE, C則是有int, double, char*...... 你問我資料型態是什麼? 能吃嗎?
恩...很可惜不能吃, 但是對電腦來說用來辨別資料很重要, 畢竟所謂的資料只不過是一堆0和1, 因此需要資料型態來辨別這堆01到底是尛, 最簡單的例子如 int 和 float 都佔了4bytes, 但一個只能表示整數另外一個則可以表示小數。

接下來我們可以依照函式資料原型來定義要用到的函式了
static rgss_obj_ivar_get_proto rgss_obj_ivar_get;     // instance_variable_get
static rgss_obj_ivar_set_proto rgss_obj_ivar_set;     // instance_variable_set
static rgss_ary_at_proto rgss_ary_at;                        // array.at
static rgss_ascii_new_cstr_proto rgss_ascii_new_cstr; // 把C/C++內的字串轉為Ruby的字串, ascii碼有的限定
static int init_ok = false;  // 用來檢定DLL初始化的旗標 (理論上應該要用bool啦, 不過還是寫 int了)
欸...等等, 前面加一個static是衝啥?
簡單來說, 前面有static修飾的東西在程式結束之前都會存在固定的位置, 所以接下來call DLL的時候才不會出現東西突然不見的奇異事件(別忘了這可是萬惡的Windows)

來到標頭檔的結尾:
void DLL_EXPORT Initialize(HMODULE){ ... }
DWORD DLL_EXPORT InitOK();
VALUE DLL_EXPORT HelloWorld();
一般的函數宣告, 其中 Initialize的HMODULE參數是由腳本內要丟過來的RGSS301.dll在記憶體中的位置, 這樣才能將上面宣告的必要ruby函式映射到記憶體中。
DLL_EXPORT在Code::Blocks的修飾意義則是該函式可以被外部呼叫, 同等於Visual Studio中的 __declspec(dllexport)

[main.cpp]
理論上來說好的專案應該要把不同的功能分在不同的檔案裡, 不過這裡因教學需要所以就都寫在一起了。 那一開始我們只需要先看這兩個函式就好:
DWORD DLL_EXPORT InitOK() { return init_ok; }
VALUE DLL_EXPORT HelloWorld() {    
    if (!init_ok) { return -1; }    
    return rgss_ascii_new_cstr("Hello World from RGSS-Extension!");
}
上面那個函式是給RGSS用來確認DLL是否成功初始化
第二個嘛....是的...你沒猜錯...又是HelloWorld (XD)
不過回傳的東西會是VALUE的記憶體位址 不是字串本身

編譯好後把IDE幫你產生的dll複製到遊戲的根目錄下(或其他位置, 隨你高興, 路徑對就好)
接著開始寫幾行腳本將DLL準備好並初始化:
[RGSS腳本]
# RGSS301.dll 的路徑
RGSSPath = "System/RGSS301.dll"
# 剛剛編譯好的DLL的路徑, 這裡我是放根目錄
RGSSExtPath = "RGSSExt.dll"
# 需要用到的Windows API
LoadLibrary = Win32API.new('kernel32', 'LoadLibraryA', 'p', 'i')
GetLastError = Win32API.new('kernel32', 'GetLastError', 'v', 'l')
假設對Win32API不熟的話, 這裡簡單快速介紹一下如何使用:
anAPI = Win32API.new('DLL名稱', '函式名稱', '參數資料型態', '回傳資料型態')
資料型態有:
v - void
p - pointer
i - int
l - long
#假設該函式需要多個參數, 如 foo(LPSTR, int) 則參數資料型態那邊應填 'pi'
要使用的時候: anAPI.call(<要丟的參數>)
緊接著將DLL初始化:
RGSSDLL = LoadLibrary.call(RGSSPath)
if RGSSDLL == 0  
  raise LoadError, "Failed to load #{RGSSPath}, error code: #{GetLastError.call()}"
else
  puts "#{RGSSPath} loaded at 0x#{RGSSDLL.to_s(16)} (#{RGSSDLL})"
end
RGSSEXTDLL = LoadLibrary.call(RGSSExtPath)
if RGSSEXTDLL == 0
  raise LoadError, "Failed to load #{RGSSPath}, error code: #{GetLastError.call()}"
else
  puts "#{RGSSExtPath} loaded at 0x#{RGSSEXTDLL.to_s(16)} (#{RGSSEXTDLL})"
end
若DLL成功載入, Windows API會回傳DLL所載入的記憶體位置, 如果是0就代表載入失敗並需要用GetLastError來取得錯誤代碼, 接著...當然就是爬文找答案

終於到了本節最後:
# 將從DLL收到的物件位置轉回Ruby物件
def VALUE2obj(address)
  return ObjectSpace._id2ref(address >> 1)
end
# 初始化自幹好的DLL
Win32API.new(RGSSExtPath, "Initialize", 'l', 'v').call(RGSSDLL)
puts VALUE2obj(Win32API.new(RGSSExtPath, "HelloWorld", 'v', 'l').call())
當初取得物件記憶體位置的方法為object_id << 1, 那要從記憶體位置變回object_id當然就是 address >> 1, 最後再用ObjectSpace._id2ref(object_id) 把它變回原有的Ruby物件
執行遊戲...應該就可以在RGSS console上看到Hello World了!

------------------ ~ 我是分隔線 ~ ------------------
[資料如何在C和RGSS互相存取]
終於來到了本篇的尾聲...(幹好累)
這裡主要將會展示如何將C和Ruby間互相轉換資料. 而資料的對應方式可在ruby.h中找到:
enum ruby_special_consts {
   RUBY_Qfalse = 0,
   RUBY_Qtrue  = 2,
   RUBY_Qnil   = 4,
   RUBY_Qundef = 6,
   // 0x開頭的數字代表16進位
   RUBY_IMMEDIATE_MASK = 0x03,
   RUBY_FIXNUM_FLAG    = 0x01,
   RUBY_SYMBOL_FLAG    = 0x0e,
   RUBY_SPECIAL_SHIFT  = 8
};
#define RSHIFT(x,y) ((x)>>(int)y)
#define INT2FIX(i) ((VALUE)(((SIGNED_VALUE)(i))<<1 | FIXNUM_FLAG))
#define LONG2FIX(i) INT2FIX(i)
#define FIX2LONG(x) RSHIFT((SIGNED_VALUE)x,1)
#define ID2SYM(x) ( ( (VALUE)(x)<<RUBY_SPECIAL_SHIFT ) | SYMBOL_FLAG )
#define SYM2ID(x) RSHIFT((unsigned long)(x),RUBY_SPECIAL_SHIFT)
// 上面那行拆開來 => ( (unsigned long)x >> 8)
根據上面原始碼, 我們便可以對各個相對的Ruby Class定義method:
# Ruby Constants
RUBY_Qfalse = 0
RUBY_Qtrue  = 2
RUBY_Qnil   = 4
RUBY_Qundef = 6
RUBY_IMMEDIATE_MASK = 0x03
RUBY_FIXNUM_FLAG    = 0x01
RUBY_SYMBOL_FLAG    = 0x0e
RUBY_SPECIAL_SHIFT  = 8

# 回傳物件記憶體位置
class Object
  def ptr; object_id << 1; end
end
class TrueClass
  def ptr; return RUBY_Qtrue; end
end
class FalseClass
  def ptr; return RUBY_Qfalse; end
end
class NilClass
  def ptr; return RUBY_Qnil; end
end
# Fixnum和Symbol的object_id不用特別處理
class Fixnum
  def ptr; object_id; end
end
class Symbol
  def ptr; object_id; end
end
# 把從C回傳的物件記憶體位置轉回Ruby物件
def VALUE2obj(address)
  return ObjectSpace._id2ref(address >> 1)
end
# 把收到回傳Symbol的VALUE轉回Symbol
def ID2SYM(cvalue)
  return ObjectSpace._id2ref(cvalue >> RUBY_SPECIAL_SHIFT)
end
這樣一來, 當要傳東西給dll的時候只要一個 .ptr 就能將相對的數值傳過去了
在C裡面, 也可以將常用的功能寫成函數以方便使用
VALUE load_int(VALUE obj, char* name){
    int re = rgss_obj_ivar_get(obj, rgss_ascii_new_cstr(name));
    return re >> 1;
}
VALUE load_obj(VALUE obj, char* name){
    return rgss_obj_ivar_get(obj, rgss_ascii_new_cstr(name));
}
VALUE set_obj(VALUE obj, char* name, VALUE val){
    return rgss_obj_ivar_set(obj, rgss_ascii_new_cstr(name), val);
}
因為常常會需要用到insance_variable_get/set來存取物件下的變數, 所以將這些函數打包起來使用會更加方便, 那要注意的地方是如果要將整數運算結果存回Ruby物件時, 要使用 INT2FIX 或是 LONG2FIX 把他轉變回Ruby看得懂的Fixnum

實際操作部份建議直接看demo source code比較好了解,如果有任何問題歡迎提出~
本文到此宣告結束, 希望對大家有幫助><
有耐心能從頭看完的人才是真正的勇者
19
-
LV. 43
GP 39k
2 樓 解凍豬腳 johnny860726
GP1 BP-
 
這個真不錯

以前離開 R 界、開始沒碰 RM 的時候

在當時的環境,正在學著寫或是本來就會寫 code 的人不多

能會 RGSS 的玩家就已經很稀有

八年的時光過去,回來看到竟然有人在玩內建 DLL

這短期間 R 界到底發生了什麼事

不可思議 XD
 
1
-
未登入的勇者,要加入 3 樓的討論嗎?
板務人員:

2676 筆精華,10/03 更新
一個月內新增 3
歡迎加入共同維護。


face基於日前微軟官方表示 Internet Explorer 不再支援新的網路標準,可能無法使用新的應用程式來呈現網站內容,在瀏覽器支援度及網站安全性的雙重考量下,為了讓巴友們有更好的使用體驗,巴哈姆特即將於 2019年9月2日 停止支援 Internet Explorer 瀏覽器的頁面呈現和功能。
屆時建議您使用下述瀏覽器來瀏覽巴哈姆特:
。Google Chrome(推薦)
。Mozilla Firefox
。Microsoft Edge(Windows10以上的作業系統版本才可使用)

face我們了解您不想看到廣告的心情⋯ 若您願意支持巴哈姆特永續經營,請將 gamer.com.tw 加入廣告阻擋工具的白名單中,謝謝 !【教學】