與Ogre共舞:第六步,敲敲鍵盤、動動滑鼠
從建立了Ogre的視窗開始,我們的目標就是透過攝影機,來看所設計的3D場景。為了能夠自由自在的瀏覽,我們得用輸入裝置來控制攝影機的位置與拍攝角度。由於Ogre本身只是個純繪圖引擎,對於處理鍵盤滑鼠之類的裝置非常不擅長,因此特地請來另外一個小巧簡單的套件 – OIS(Object Oriented Input System,別問我為什麼不叫OOIS呀…),幫忙解決這個問題。而這所有的過程,都會由FrameListener來接管。我們將繼續上一步的FrameListener範例,添加了滑鼠鍵盤輸入的處理,達到控制攝影機的目的。
先來簡單介紹一下OIS,這是個跨平台處理輸入裝置的函式庫,以物件導向的方式設計。OIS可以支援的輸入裝置主要是鍵盤、滑鼠、搖桿,同時也支援力回饋裝置。Ogre在編譯的時候,已經把OIS當成其中的一部分,所以我們可以簡單的使用,而不需要另外作安裝或設定的動作。不過,OIS本身沒有提供說明文件,Ogre的API裡也查不到(當然的嘛~),所以除了Wiki上有得找以外,就得直接看source code,或是OIS的API參考文件囉!
使用OIS,必須定義OIS_DYNAMIC_LIB與引入”OIS.h”,順序不能錯,這樣OIS才會正確的使用dll檔的export的定義。接著,我們接受輸入裝置的目標是視窗,因此要讓OIS知道是哪一個視窗取得輸入裝置的訊息。OIS的初始化比較囉唆一點,因為一開始要準備初始化用的參數,看起來就很嚇人。下段code中,第3行的m_render_window是我們所建立的RenderWindow的指標,由其成員函式getCustomAttribute()取得視窗的handle。第一個傳入參數”WINDOW”是RenderWindow屬性的鍵值,是固定的寫法。得到的window_handle隨後由第6~7兩行轉換為字串,準備給OIS的初始化參數使用。
// retrieve the handle of the render window
DWORD window_handle = NULL;
m_render_window->getCustomAttribute( "WINDOW", &window_handle );
// convert the handle to std::string(stream)
ostringstream window_handle_str;
window_handle_str < < window_handle;
接著要產生OIS初始化用的參數列,先宣告OIS::ParamList的變數一枚,接著用insert()加入參數對(std::pair)。下面的程式碼中,第4行就是把剛剛取得的RenderWindow的handle,對應鍵值為”WINDOW”,加入初始化的參數中。到這裡OIS的初始化參數已經算是建立完成了,可以建立OIS的InputManager。在全螢幕或是視窗的應用上,通常(特別是遊戲)不會讓滑鼠的游標露出來給你看,或是在遊戲中使用自訂的滑鼠游標。第8~9行的作用是讓滑鼠的游標可以顯示出來,就像一般的視窗應用程式那樣。DISCL_FOREGROUND與DISCL_NONEXCLUSIVE設定了滑鼠的前景模式與非獨站模式[註1],定義在DirectX的DirectInput中。
// initialize OIS
// 1. prepare the parameter list
OIS::ParamList param_list;
param_list.insert( make_pair( string( "WINDOW" ), window_handle_str.str() ) );
// default mode is foreground exclusive
// but if we want to show the mouse cursor, set to non-exclusive mode
param_list.insert( make_pair( string( "w32_mouse" ), string( "DISCL_FOREGROUND" ) ) );
param_list.insert( make_pair( string( "w32_mouse" ), string( "DISCL_NONEXCLUSIVE" ) ) );
初始化參數設定好後,交由OIS建立InputManager(第2行)。接著,由InputManager建立輸入裝置的物件。前面提過,OIS支援許多輸入裝置,但一般電腦以鍵盤與滑鼠最為普遍。第5~6行分別建立鍵盤輸入與滑鼠輸入的物件,稍候取得輸入裝置的事件就由這兩個物件來操作。要注意的是,如果該裝置不存在,可是會丟出例外的!因為大家操作電腦都會有鍵盤跟滑鼠,所以這邊就省略了例外處理。建立輸入裝置物件的第二個參數,’false’,表示使用無緩衝的輸入模式(unbuffered input),另外一種當然是緩衝式輸入(buffered input)了。這兩種輸入方式最大的差異是,如果用緩衝式輸入模式,輸入裝置會以事件通知的方式來傳遞輸入訊號。這種處理會以Listener的方法,來判斷哪個按鍵被按下,或是滑鼠動了多少。而無緩衝的輸入模式,需要由程式主動檢查輸入裝置的狀態,來獲取輸入的訊號。相較之下,無緩衝的輸入方式處理上較為簡單,因此先以無緩衝的為主。
// 2. create OIS input manager (by using the parameter list)
m_ois_input_mgr = OIS::InputManager::createInputSystem( param_list );
// 3. create mouse and keyboard devices (unbuffered ONLY)
m_ois_keyboard = (OIS::Keyboard *)m_ois_input_mgr->createInputObject( OIS::OISKeyboard, false );
m_ois_mouse = (OIS::Mouse *)m_ois_input_mgr->createInputObject( OIS::OISMouse, false );
OIS的部份暫告一段落,先來介紹另一個協助處理視窗狀態的小幫手:WindowEventListener。看到Listener,就想到這是個聽候差遣的類別。沒錯,這個WindowEventListener負責視窗改變事件的觸發,如尺寸改變、被關閉、被移動等等。WindowEventListener提供虛擬函式,因此需要一個衍生類別來重載虛擬函式。在Ogre的範例程式”ExampleFrameListener.h”中,ExampleFrameListener類別繼承了FrameListener與WindowEventListener,在這裡,我們也以那樣的方式同時繼承這兩個類別。類別的宣告如下面那樣:
class MyOgreFrameListener: public FrameListener, public WindowEventListener
{
public:
MyOgreFrameListener( RenderWindow *_rwin, Camera *_cam);
~MyOgreFrameListener();
public: // WindowEventListener virtual functions
void windowResized( RenderWindow *_rwin );
void windowClosed( RenderWindow *_rwin );
public: // FrameListener virtual functions
bool frameRenderingQueued( const FrameEvent &_evt );
bool frameEnded( const FrameEvent &_evt );
private:
RenderWindow* m_render_window;
Camera* m_camera;
SceneManager* m_scene_mgr;
Real m_time_until_next_toggle;
// OIS input
OIS::InputManager* m_ois_input_mgr;
OIS::Keyboard* m_ois_keyboard;
OIS::Mouse* m_ois_mouse;
};
和上一步相比,成員函式多了windowResized()與windowClosed(),分別處理視窗尺寸改變與被關閉時該做的動作。視窗尺寸改變的時候,要處理的事情只有設定新的縱橫比[註2]。我們只有一個Viewport,因此getViewport()的參數永遠是’0′。
void MyOgreFrameListener::windowResized(RenderWindow *_rwin)
{
// Set the aspect ratio
Viewport* viewport = m_render_window->getViewport( 0 );
// or use : Viewport* viewport = m_camera->getViewport(); when you have only one viewport.
m_camera->setAspectRatio( Real( viewport->getActualWidth() ) / Real( viewport->getActualHeight() ) );
}
而windowClosed()的處理也不複雜,就是將建立的OIS輸入物件釋放掉,最後釋放掉InputManager。話說,這個windowClosed()事件的觸發,是要「使用者關閉視窗」的動作發生時(像是按下視窗的’x'按鈕關閉)才會被觸發。如果是由程式關閉的,它也不會被作動。
void MyOgreFrameListener::windowClosed(RenderWindow *_rwin)
{
// release the input system
if( _rwin == m_render_window )
{
if( m_ois_input_mgr )
{
m_ois_input_mgr->destroyInputObject( m_ois_mouse );
m_ois_input_mgr->destroyInputObject( m_ois_keyboard );
OIS::InputManager::destroyInputSystem( m_ois_input_mgr );
m_ois_input_mgr = NULL;
}
}
}
回到WindowEventListener類別,當我們繼承並實做其中的函式後,當然要讓Ogre認識這個Listener。下面就是向目前的描繪視窗(m_render_window)註冊我們定義的WindowEventListener類別。不意外的,那個’this’就是當前的WindowEventListener類別(也就是MyOgreFrameListener)。
// register as a Window listener
Ogre::WindowEventUtilities::addWindowEventListener( m_render_window, this );
看了一堆,休息一下吧!接下來就是真正的主角:如何偵測鍵盤與滑鼠的輸入,來控制攝影機。對一個無緩衝的輸入裝置,要知道裝置目前的狀態(哪個鍵被按下),必須一直不斷地去偵測。FrameListener提供的事件是最恰當的,因為每描繪一張畫面,就要執行一次。以下開始,將說明MyOgreFrameListener::frameRenderingQueued()裡的程式碼。
if ( m_render_window->isClosed() ) return false;
// need to capture/update each input device
m_ois_mouse->capture();
m_ois_keyboard->capture();
很久很久以前,Hello, Ogre!視窗就一直沒辦法好好的關掉,不,是就算被關起來,自動描繪迴圈也不會結束。現在,只要讓false被回傳,繪圖迴圈就會結束了。第1行的任務,就是當視窗被關閉的時候,回傳false。第4~5行,分別擷取滑鼠與鍵盤目前的狀態。
// parameters of the camera control
const Real move_scale = 100 * _evt.timeSinceLastFrame;
const Real rot_scale = 36 * _evt.timeSinceLastFrame;
Vector3 cam_translate( Vector3::ZERO );
Radian rot_x( 0.0 );
Radian rot_y( 0.0 );
為了要控制攝影機動作,一些關於攝影機控制的參數在這裡被定義。move_scale與rot_scale分別表示每次移動與旋轉的量,受到timeSinceLastFrame的作用,移動量為每秒100個單位而旋轉量為每秒36度(degree)。cam_translate為相機的位移(三個軸向),rot_x與rot_y則是相機左右轉動與俯仰傾的弧度。這些數值會由鍵盤與滑鼠的輸入,產生對應的值,達到改變相機位置與拍攝角度的目的。
// leave the rendering-loop
if ( m_ois_keyboard->isKeyDown( OIS::KC_ESCAPE ) )
return false;
// take a screen shot with a time stamped filename
if ( m_ois_keyboard->isKeyDown( OIS::KC_F12 ) && m_time_until_next_toggle < 0.0 )
{
m_render_window->writeContentsToTimestampedFile( "screenshot", ".png" );
m_time_until_next_toggle = 0.333;
}
// move camera left
if ( m_ois_keyboard->isKeyDown( OIS::KC_A ) ) cam_translate.x = -move_scale;
// move camera right
if ( m_ois_keyboard->isKeyDown( OIS::KC_D ) ) cam_translate.x = move_scale;
// move camera forward
if ( m_ois_keyboard->isKeyDown( OIS::KC_W ) ) cam_translate.z = -move_scale;
// move camera backward
if ( m_ois_keyboard->isKeyDown( OIS::KC_S ) ) cam_translate.z = move_scale;
上面這段程式碼,處理鍵盤輸入的對應動作。檢查鍵盤上的某個按鍵是否被按下,可以用OIS::Keyboard::isKeyDown()的回傳值來判斷。如果該按鍵有被按下,則函式回傳true,按鍵碼(KC_xxx)可以參考”OISKeyboard.h”裡的定義。這裡比較特別的是第5~9行的敘述,在按下F12按鍵後,會將目前畫面擷取後存為檔名”screenshot”加上時間戳記的png圖檔。而這個m_time_until_next_toggle的處理是比較有趣的,後面再詳細說明。
const OIS::MouseState &mouse_state = m_ois_mouse->getMouseState();
if( mouse_state.buttonDown( OIS::MB_Right ) )
{
cam_translate.x += mouse_state.X.rel * 0.15;
cam_translate.y -= mouse_state.Y.rel * 0.15;
}
else
{
rot_x = Degree( -mouse_state.X.rel * 0.15 );
rot_y = Degree( -mouse_state.Y.rel * 0.15 );
}
緊接著是滑鼠輸入的處理。在OIS::Mouse::capture()之後,滑鼠的輸入狀態會被保存在OIS::Mouse物件中,透過getMouseState()來取得。第3行是判斷滑鼠的哪個按鍵被按下,和鍵盤按鍵一樣,參考”OISMouse.h”可以查詢所定義的按鍵。在OIS::MouseState的定義中,滑鼠包含三個軸(OIS::Axis),X,Y,Z,X與Y就不用說了,Z軸就是滾輪的動作。而每個軸產生的輸入量,則由其資料成員abs表示絕對量(這個超難用),或是rel表示相對量。
// update the camera
m_camera->yaw( rot_x );
m_camera->pitch( rot_y );
m_camera->moveRelative( cam_translate );
return true;
MyOgreFrameListener::frameRenderingQueued()裡的最後幾行敘述,就是將鍵盤輸入與滑鼠輸入的量,轉換為相機位移與旋轉的量後,更新相機的位置與角度。由於輸入的是相對量,相機的移動與轉動也是採用相對的方式。在最後,回傳true,讓描繪迴圈繼續執行下去。
最後,來談談關於無緩衝輸入的一個有趣的問題,與m_time_until_next_toggle這個變數的作用。當我們在按下按鍵的時候,我們會希望該按鍵能啟動某個動作,像是移動相機,或是開關燈光。別忘了按鍵的偵測是「每個畫面」檢查的,因此移動相機的時候,也許感覺不出來(我按著相機就動,一直動),但是如果是切換某個狀態呢?通常,按下按鍵到放開按鍵,會花上一段時間。這段時間對人來說非常短,但對一個每秒鐘畫上幾十幾百張的Ogre來講,實在是長得太多啦!因此,按鍵按下的時候,可能會被OIS發現保持「按下」的狀態好幾次,而導致狀態的改變(像是燈光開關)每張畫面都切換一次(開關開關…),這是個很糟也很蠢的事情。m_time_until_next_toggle變數,就是企圖利用一個倒數的時間,保證在該時間內不會有重複發生按鍵按下的對應事件被觸發。

上圖說明了按鍵按下去到起來時,發生在Ogre::FrameListener與OIS擷取鍵盤狀態的示意圖。淺灰虛線是每一次frameRenderingQueued()被執行的開始,假設很規律地每10ms一次。同時,假設按鍵按下去到放開會花上150ms,這時,Capture的綠色區塊表示了按鍵#1被按下時,OIS::Keyboard所擷取到按鍵被按下的動作,看起來真不妙啊!如果是控制電燈開關,那就每10ms切換一次開或關,燈會被玩壞的。而Capture w/ countdown則是加上一段延遲時間的控制,這裡設定time_until_next_toggle為200ms長,可以發現當第一次按鍵被偵測後(橘色區塊),time_until_next_toggle會被減去timeSinceLastFrame,也就是10ms。如果在 if-判斷句中設定time_until_next_toggle小於0才成立,那就會如圖那樣,只要time_until_next_toggle還沒倒數完,就不可能產生對應事件的觸發了。如果又有個按鍵#2被按下,那得等到time_until_next_toggle小於0後才會觸發對應事件(當然,按鍵#2的if-判斷句也要加上對time_until_next_toggle小於0的限制條件才行)。
也就是說,無緩衝輸入討厭的地方就是某些動作必須加上上述的限制條件,才能保證其執行的結果符合需求。就像前面將畫面存檔的動作,如果沒有設定延遲的時間,那光按一下F12可能就會存個好幾張圖。當然,維護這個條件也需要多一點點的成本,包括多一個m_time_until_next_toggle,以及:
bool MyOgreFrameListener::frameEnded( const FrameEvent &_evt )
{
// count down the time until the next toggle
m_time_until_next_toggle -= _evt.timeSinceLastFrame;
return true;
}
[下載] 範例程式 (VC2008 專案)
ogre_tutorial_6.zip 7.5 Kb
這次的說明為了顧及理解上的方便,沒有完全按照程式碼的順序進行。建議把範例下載後對照來看,會更加清楚。
[註1] 裝置輸入的合作模式(cooperative level),可以分為「前 / 後景模式」與「獨占 / 非獨占模式」兩種的組合。前後景模式,指示視窗在什麼狀態下能接受裝置的輸入。前景模式(DISCL_FOREGROUND)表示視窗僅在前景(focused)時接受輸入,而背景模式(DISCL_BACKGROUND)則表示視窗在背景時(unfocused),仍然可以接收裝置的輸入(在前景時亦然)。獨占模式就比較容易理解,表示目前的輸入裝置(如滑鼠)是否僅能由該視窗使用。所以,如果設定為獨占模式(DISCL_EXCLUSIVE),其他視窗就沒辦法接收裝置的輸入(看起來很像被綁架)。OIS對輸入裝置的預設值是採用「前景」且「獨占」的模式。結論是,如果對於一個「視窗化」的應用程式設定合作模式,會改變裝置對於該視窗的輸入表現。但是,對一個「全螢幕」的應用程式,前 / 後景模式的設定就不是那麼重要了。另外,鍵盤裝置是不允許使用獨占模式的。
若在OIS設定輸入裝置時,改變裝置輸入的前 / 後景模式,以本範例來說看不出效果。原因是,當Ogre發現視窗失去焦點時(unfocused),會停止描繪迴圈的處理。因此,必須在初始化Ogre時,加上RenderWindow::setDeactivateOnFocusChange( false );這個敘述,強迫在視窗變背景時,仍然處理描繪的工作,才會看出輸入裝置的前 / 後景模式所造成的不同效果。
[註2] 在”ExampleFrameListener.h”與本範例的原始碼中,會有關於設定滑鼠剪裁區域(mouse clipping area,設定滑鼠能被偵測到事件的區域)的部份,僅在X11下會有用,Windows的環境中不需要這樣設定,所以本範例中暫時被註解起來。
事實上OIS有提供API喔
http://development.dave-smith.net/OIS/
不過沒想到可以用這種方法來顯示滑鼠
我個人是跟CEGUI來顯示滑鼠這樣
不過這樣要是換成其他的GUI library就得重寫就是了
而且不能操控視窗事件更是要命的缺點Orz
等等來試試看能不能結合上面的寫法跟CEGUI在一起XD
Comment 由 艾薩克 | 四月 2, 2009
阿,忘了問一些問題
滑鼠的獨占跟非獨站是會不會顯示滑鼠的差別
那前景模式跟背景模式差別在哪?
Comment 由 艾薩克 | 四月 2, 2009
謝謝提供參考文件。CEGUI,看wiki與論壇上是個挺被推薦的好物。不過要介紹的東西實在很多,GUI晚點吧!
關於前景模式跟背景模式的差別,已經補充在[註1]中,參考看看。
Comment 由 chia0418 | 四月 2, 2009