2018年10月15日 星期一

在Android studio裡設定NDK

在Android studio裡設定NDK

前言

  最近打算讓C++的引擎支援Android,所以就安裝許久沒用的Android studio,裝完後發現NDK的用法跟我以前的用法差很多,所以想說可以換回以前的"ndk-build.cmd"嗎?答案是可以的,設定的過程不太好記,所以在此做個紀錄。

內容

  在設定NDK的第一步驟是安裝NDK,Android studio本來就有整合,只是預設不會裝。首先打開SDK Manager,如下圖
打開SDK Manager

接著選取安裝NDK,如下圖
安裝NDK

安裝完後請重新開啟Android studio。

  下一個步驟是設定External Tools,這裡會新增兩個工具,一個是javah,另一個是ndk-build。
打開設定,如下圖
打開設定

接著到External Tools新增工具,如下圖
External Tools新增工具

新增"javah",如下圖
新增"javah"
Program的參數
$JDKPath$\bin\javah.exe

Arguments的參數
-classpath "$Classpath$;" -d $ModuleFileDir$\src\main\jni $FileClass$

Working Directory的參數
$ModuleFileDir$\src\main\java\
接著新增ndk-build",如下圖
新增"ndk-build"

Program的參數
C:\Users\hosee\AppData\Local\Android\Sdk\ndk-bundle\ndk-build.cmd

Working Directory的參數
$ModuleFileDir$\src\main\
Program的參數的參數因為沒marcro,所以是個絕對位置,其實就是NDK的安裝位置,如果不要用Android studio裡的NDK,
可以把這裡的位置改掉。
接著設定打開專案結構,如下
打開專案結構

接著設定JDK,使用Android studio裡面的JDK,如下圖
設定JDK
設定的部分到此結束,接著是在專案中使用NDK。

  開一個新專案後,在gradle.properties新增以下
android.useDeprecatedNdk=true

如圖
修改gradle.properties

然後在build.gradle新增以下
ndk {
  moduleName "HelloNDK"
  ldLibs "android"
}
sourceSets.main {
  jni.srcDirs = []
  jniLibs.srcDir "src/main/libs"
}

如圖
修改build.gradle

修改完後,再來是新增jni的資料夾,如下圖
新增jni資料夾

新增後會跳出"New Android Component",直接Finish即可,如下圖
"New Android Component"視窗

  在專案的Activity裡新增以下
    static {
        System.loadLibrary("HelloNDK");
    }

    public static native String nativeHello();

如圖
新增NDK介面

介面加完後,使用"javah"來新增header檔。

  接著在jni資料夾新增source檔,檔案內容如下
#include "string.h"
#include "jni.h"
#include "com_example_hosee_hellondk_MainActivity.h"
JNIEXPORT jstring JNICALL Java_com_example_hosee_hellondk_MainActivity_nativeHello(JNI* pEnv,jclass jObj)
{
  return (pEnv)->NewStringUTF("Hello NDK!");
}

操作如圖
新增source檔

  接著在jni資料夾新增"Android.mk",內容如下
LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE := HelloNDK
LOCAL_SRC_FILES := com_example_hosee_hellondk_MainActivity.cpp
LOCAL_LDLIBS += -landroid
include $(BUILD_SHARED_LIBRARY)

操作如圖
新增"Android.mk"

  接著在jni資料夾新增"Application.mk",內容如下
APP_PLATFORM := android-15
APP_MODULES := HelloNDK
APP_ABI := all

操作如圖
新增"Application.mk"

  接著使用ndk-build產生so檔,如下圖
使用ndk-build產生so檔

最後在專案的Activity新增以下
TextView tv = (TextView) findViewById(R.id.TestTextView);
tv.setText(nativeHello() );

操作如圖
使用NDK的Function

這裡要注意TextView的ID要先去Layout裡面加完,加完後就可以使用了。


參考資料

超级简单的Android Studio jni 实现(无需命令行)

2018年10月8日 星期一

在Ubuntu裡切換GPU

在Ubuntu裡切換GPU

前言

  在筆電安裝Ubuntu後,發現Chrome跑個網頁相當吃力,本想可能是選到Intel的GPU,想做個交換GPU,發現根本沒選項,所以就來研究怎麼切換GPU,在此做個紀錄。

內容

  研究後發現Ubuntu根本沒安裝顯卡驅動,所以跑個網頁就相當吃力。怎麼安裝顯卡驅動呢?
在"系統設定值"裡有個"軟體與更新",如下圖
軟體與更新
進去後選取"額外的驅動程式"頁籤,如下圖
安裝驅動程式
可以看到下方有個列表,預設的驅動程式是在第三個選項,將它選到第一個選項進行安裝,安裝後再重新啟動。

  安裝完驅動程式後,可以開啟顯卡驅動所附帶的軟體來切換GPU,以nVidia的為例就是開啟"NVIDIA X Server Settings",如下圖
NVIDIA X Server Settings

開啟後,在左側選取選項"PRIME Profiles",如下圖
選取GPU
接著就可以在右側進行切換GPU了。

參考資料

Switch Between Intel and Nvidia Graphics Card in Ubuntu 16.04

2018年10月1日 星期一

使用python來備份SVN

使用python來備份SVN

前言

  備份SVN這件事以前都不是我會負責的事,但一定有做,最近添購了一台NAS,想想是個機會,就來研究一下,在此做個紀錄。

內容

  如果需要備份SVN的話就需要"svnadmin",所以請先確認該命令是否可以使用。備份SVN的命令如下
svnadmin dump c:\svnrepo --incremental > c:\backup\xxx.dump

紅色的部分是檔案庫的位置,藍色的則是輸出的備份檔,請依據需求更改。如果要用備份檔來還原檔案庫的話命令如下
svnadmin load c:\svnrepo < c:\backup\xxx.dump

紅色的部分是還原檔案庫的位置,藍色的則是使用的備份檔。整個過程用起來相當簡單,但備份這件事通常會希望是"自動執行"的,而不是需要用"手動執行"命令才動作,所以接著來介紹如何自動執行。

  排程這個功能在Windows與Linux都有這個功能,它可以用來每隔一段時間來時執行某個命令,幸運的,買來的NAS有視窗化的操作來編輯排程,所以這部份很快就完成了,至於Windows與Linux的排程實際上怎麼使用以後有機會再研究。

  使用Python來執行備份命令,為什麼不直接用bash呢?唯一的考量就是跨平台,加上Python使用起來比較靈活,所以就決定用Python了。執行的時候通常會希望可以依據備份的時間來分資料夾,所以我實作了以下的範例碼
import os
import time
backUpRootDir = '/homes/hosee/SVNBackUp/'
backUpFileName = 'svnBackUp.dump'
#
dirName = time.strftime("%Y%m%d_%H%M%S", time.localtime() )
os.system('mkdir -p '+backUpRootDir+dirName)

os.system('svnadmin dump /homes/hosee/svn/repo --incremental > '+backUpRootDir+dirName+'/'+backUpFileName)

這個範例碼會依據備份的時間製作資料夾,並在該資料夾儲存備份檔。範例碼的路徑請依據需要修改,如"backUpRootDir"與svnadmin的檔案庫位置。最後,就是只要用排程來啟動Python的script就完成了。

參考資料

subversion的備份hotcopy以及dump的使用

2018年9月24日 星期一

初探Chromium Embedded Framework

初探Chromium Embedded Framework

前言

  Chromium Embedded Framework以下簡稱為"cef",是一個可以在Native App裡使用Chrome的Framework,知名的"Electron"的前端就是"cef",由於是個開源專案,所以來研究一下在此做個紀錄。

內容

  如果上網搜尋很容易會找到開源專案的位置 Chromium Embedded Framework Overview,開啟後會得到Git的URL,如下圖
cef的Git
如果就這麼開開心心的用Git直接Clone的話,就等於在"浪費時間",因為那個Git並不是用來給使用者存取的,而是給"cef"的自動建置流程來存取的!所以下載完後會發現根本不能建置。那麼該如何建置"cef",請參考MasterBuildQuickStart。

  在開啟MasterBuildQuickStart後,先看"File Structure",如下圖
cef的File structure
這個"File Structure"有一部分是要手動建構的,不是自動產生的,該網頁還會說明各個平台建置需求與步驟,以下說明為Windows平台。雖說網頁上說要準備40G的空間,但我實際建置64位元Debug版後,發現大概要55G,建立前請確認空間是否充足。

  第一步是建立"automate"與"chromimu_git"資料夾,說明的Root資料夾為"c:\code",請示需求更改Root資料夾。

  第二步是在Root資料夾下建立"depot_tools"資料夾,並下載說明中的"depot_tools.zip",如下圖
下載"depot_tools.zip"
載完後,解壓到"depot_tools"資料夾下即可。

  第三步是執行"update_depot_tools.bat",這個檔案在"depot_tools"資料夾裡,但在執行前請確認Python的版本,限定2.7.x版才可以成功建置,如果是3以後的版本請切換。

  第四步是將"depot_tools"資料夾的位址加到環境變數"path"裡。

  第五步是下載"automate-git.py",如下圖
下載"automate-git.py"
下載後放到"automate"資料夾下。

  第6步是製造"update.bat",到"chromimu_git"的資料夾下新增一個"update.bat",內容如下
set GN_DEFINES=use_jumbo_build=true
set GN_ARGUMENTS=--ide=vs2017 --sln=cef --filters=//cef/*
python ..\automate\automate-git.py --download-dir=c:\code\chromium_git --depot-tools-dir=c:\code\depot_tools --no-distrib --no-build

注意一下紅色的內容,有兩個檔案位置會因為Root資料夾的位置不一樣而改變,請示需要修改。改完後直接執行"update.bat"。這個步驟有點就大概要一個半小時,執行中如果中斷就要重來,請先注意。

  第7步是製造"create.bat",到"(root)\chromimu_git\chromium\src\cef"下,製造"create.bat",內容如下
set GN_DEFINES=use_jumbo_build=true
set GN_ARGUMENTS=--ide=vs2017 --sln=cef --filters=//cef/*
call cef_create_projects.bat

改完後就直接執行。

  第8步就是啟動自動建置,到"(root)\chromimu_git\chromium\src",執行以下命令
ninja -C out\Debug_GN_x64 cef

這種個建置過程非常久,在我的電腦是3個小時,請注意。如果建置完成就可以在"(root)\chromimu_git\chromium\src\out\Debug_GN_x64"下找到"cefclient.exe",執行後就可得到一個cef的視窗。


參考資料

MasterBuildQuickStart

2018年9月17日 星期一

DirectInput的學習心得(2)

DirectInput的學習心得(2)

前言

  續前篇DirectInput的學習心得(1),本次紀錄用DirectInput來偵測手把(Gamepad)。為何不直接使用XInuput呢?為了相容一些較舊的手把,就這麼簡單。如果同時使用DirectInput與XInuput時會有一些要注意的,就是DirectInput可以抓到XInuput的手把,本篇會記錄如何濾除,在此做個紀錄。

內容

  在開始偵測手把時,要先製造出IDirectInput8,如果不知道如何製造,請參照前篇DirectInput的學習心得(1),接著跟上次鍵盤的狀況不太一樣,由於系統鍵盤只會有一個,所以不需要去列舉裝置,但手把可能不只一個,所以透過EnumDevices()來抓出有多少個手把,接著對每個手把做SetCooperativeLevel()與SetDataFormat(),這部分和上次的鍵盤一樣,但這次要多一個步驟,就是透過EnumObjects()來取得類比按鈕的資訊,最後完成後一樣要Acqurie()。
  來看看範例程式,如下
#include "dinput.h"
#include "dinputd.h"
struct SEnumJoystickContext
{
  IDirectInput8* pDirectInput;
  HWND hWnd;
  std::vector<IDirectInputDevice8>& deviceList;
};
BOOL CALLBACK EnumJoystickCallBack(const DIDEVICEINSTANCE* pdidInstance,VOID* pContext)
{
  //Cast context...
  SEnumJoystickContext* pEnumJoystickContext=(SEnumJoystickContext*)pContext;

  //
  LPDIRECTINPUTDEVICE8 pNewDevice=NULL;
  HRESULT hr=pEnumJoystickContext->pDirectInput->CreateDevice(pdidInstance->guidProduct,&pNewDevice,NULL);
  if(hr != DI_OK)
    return DIENUM_STOP;
  //
  pEnumJoystickContext->deviceList.push_back(pNewDevice);
  return DIENUM_CONTINUE;
}
BOOL CALLBACK EnumObjectsCallBack(const DIDEVICEOBJECTINSTANCE* pdidoi,VOID* pContext)
{
  IDirectInput8* pDevice=(IDirectInput8*)pContext;
  if(pdidoi->dwType &DIDFT_AXIS)
  {
    DIPROPRANGE diprg;
    diprg.diph.dwSize=sizeof(DIPROPRANGE);
    diprg.diph.dwHeaderSize=sizeof(DIPROPHEADER);
    diprg.diph.dwHow=DIPH_BYID;
    diprg.diph.dwObj=pdidoi->dwType;
    diprg.lMin=-1000;
    diprg.lMax=1000;
    //
    if(pDevice->SetProperty(DIPROP_RANGE,&diprg.diph)!=DI_OK)
      return DIENUM_STOP;
  }
  return DIENUM_CONTINUE;
}
void DetectedGamepad(IDirectInputDevice8* pDIDeviceGamepad)
{
  DIJOYSTATE2 gamepadState;
  pDIDeviceGamepad->GetDeviceState(sizeof(DIJOYSTATE2), &gamepadState);
  //Get x axis was left
  if(gamepadState.lX < 0)
  {
    //do something...
  }
  //Get button 0 state
  if(gamepadState.rgbButtons[0] &0x80)
  {
   //do something...
  }
}
int WINAPI wWinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nCmdShow )
{
  //Init main window and IDirectInput8
  //...
  HWND mainWin = initMainWindow;
  IDirectInput8* pDirectInput = initDirectInput;
  std::vector<IDirectinputdevice8*> deviceList;
  //
  SEnumJoystickContext enumJoystickContext = { pDirectInput, mainWin, deviceList};
  pDirectInput-&gt;EnumDevices(DI8DEVCLASS_GAMECTRL, EnumJoystickCallBack, &enumJoystickContext, DIEDFL_ATTACHEDONLY) 
  for(int i=0;i<deviceList.size();i++)
    pGamepadDevice->SetCooperativeLevel(mainWin,DISCL_FOREGROUND | DISCL_NONEXCLUSIVE);
    pGamepadDevice->SetDataFormat(&c_dfDIJoystick2);
    //
    pGamepadDevice->EnumObjects(EnumObjectsCallBack, pGamepadDevice, DIDFT_ALL);
    pGamepadDevice->Acquire();
  }
  //Init gamepad end...
}

在製造完IDirectInput8後,會看到一個SEnumJoystickContext的Struct與 EnumDevices(),SEnumJoystickContext是要傳給EnumDevices()的參數,再看看EnumDevices()的參數會發現一個CallBack function名為EnumJoystickCallBack,這個CallBack 會在每次被IDirectInput8列許到裝置後被喚起,看看喚起的EnumJoystickCallBack(),有個引數名為"pContext",會發現在開頭就被轉型為SEnumJoystickContext,也會看到另一個引數"pdidInstance",這次必須透過DIDEVICEINSTANCE來製造出IDirectInputDevice8,這個CallBack 的回傳值有"DIENUM_CONTINUE"與"DIENUM_STOP","DIENUM_CONTINUE"表示持持續列舉,"DIENUM_STOP"則代表中止列舉,在EnumDevices()結束後,所有的IDirectInputDevice8都會在"deviceList",接著就初始化每個裝置。初始化裝置和鍵盤很像,一樣是SetCooperativeLevel()與SetDataFormat(),但這次要多一個步驟,就是EnumObjects(),EnumObjects()一樣需要一個CallBack ,這個CallBack 主要用來初始化類比按鍵,最後一樣要Acquire()。偵測的按鍵的部分可以參考範例的DetectedGamepad(),透過GetDeviceState()可以得到"DIJOYSTATE2",裡面就會放置所有按鍵的值,詳細地的說明可以到DIJOYSTATE2 Structure查詢。
  如果需要與XInuput同時使用,官方的提供的做法是過濾掉XInuput的裝置,官方提供一個檢查是否為XInuput裝置的function,這個function可以用在EnumJoystickCallBack()裡去做濾除的動作,詳細的程式碼可以在XInput and DirectInput裡找到。

參考資料

XInput and DirectInput
DIJOYSTATE2 Structure

相關文章

DirectInput的學習心得(1)

2018年9月10日 星期一

DirectInput的學習心得(1)

DirectInput的使用(1)

前言

  在偵測輸入時可以採用XInput來偵測,可以參考XInput的使用這一篇,但XInput並不支援鍵盤與滑鼠,且手把要是年代較久遠的話也會無法被XInput偵測到。為什麼不用訊息幫浦來偵測輸入呢?理由很簡單,作業系統的訊息幫浦沒辦法調整偵測的速度,像現代電玩時常需求的FPS60偵測速度是達不到的,就我以前實測的經驗,訊息幫浦不但偵測速度不夠,而且偶爾還有掉輸入(明明有輸入卻沒反應),雖然不知是否為偵測速度不夠造成,但用DirectInput偵測鍵盤的輸入,這個狀況就可以得到解決。這次的紀錄只會記錄到鍵盤的偵測與使用,在此做個紀錄。

內容

  使用DirectInput的過程並不會太繁雜,首先使用DirectInputCreate()製造出IDirectInput8,這個介面只需造一次即可,接著用IDirectInput8來製造出IDirectInputDevice8,而依據製造的參數不同可以讓IDirectInputDevice8代表鍵盤、滑鼠與手把,本篇將說明如何將IDirectInputDevice8初始化為鍵盤。
  初始化鍵盤的範例碼如下

#include <dinput>
#include <dinputd>
void DetectedKeyboardKeys(IDirectInputDevice8* pDIDeviceKeyboard)
{
  unsigned char keyState[256];
  if(pDIDeviceKeyboard->etDeviceState(256,keyState) != DI_OK)
    return;
  //if GetDeviceState() is fined,keyState will save all key state from keyboard...
  //Follow detect sample...
  //Is num 0 pressed? 
  if(keyState[DIK_0])
  {
    //do something...
  }
  //Is key a pressed? 
  if(keyState[DIK_A])
  {
    //do something...
  }
  
}
int WINAPI wWinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nCmdShow )
{
  //Init main window
  //...
  HWND mainWin = initMainWindow;
  //Init DiectInput...
  IDirectInput8* pDirectInput = nullptr;
  if(DirectInput8Create(hInstance,DIRECTINPUT_VERSION,IID_IDirectInput8,(void**)&pDirectInput ,NULL) != DI_OK)
    return 1;
  //Init keyboard
  IDirectInputDevice8* pDIDeviceKeyboard = nullptr;
  if(pDirectInput->CreateDevice(GUID_SysKeyboard,&pDIDeviceKeyboard ,NULL) != DI_OK)
  {
    pDirectInput->Release(); 
    return 1;
  }
  //
  if(pDIDeviceKeyboard->SetCooperativeLevel(mainWin,DISCL_FOREGROUND|DISCL_NONEXCLUSIVE) != DI_OK)
  {
    pDIDeviceKeyboard->Release();
    pDirectInput->Release(); 
    return 1;
  }
  if(pDIDeviceKeyboard->SetDataFormat((DIDATAFORMAT*)&amp;c_dfDIKeyboard) != DI_OK)
  {
    pDIDeviceKeyboard->Release();
    pDirectInput->Release(); 
    return 1;
  }
  //
  pDIDeviceKeyboard->Acquire();
  //Init keyboard end
  // 
  return 0;
}

在主程式一如之前所說製造出IDirectInput8,要注意HINSTANCE是必要的參數,再來是透過 IDirectInput8的CreateDevice()來製造出IDirectInputDevice8,並可載參數上看到"GUID_SysKeyboard",這個參數會決定裝置是鍵盤、滑鼠或手把,接著是SetCooperativeLevel(),主要的目的確定要偵測哪個視窗的輸入,SetDataFormat()決定偵測資料的輸出格式,在鍵盤時這是固定的,所以用c_dfDIKeyboard來設定即可,接著Acquire(),這個過程很重要,初始化的最後一個流程,勢必要流程請勿省略,Acquire()如果失敗表示該裝置目前沒連上,以上就是初始化鍵盤的部分。偵測的部分在DetectedKeyboardKeys(),首先準備好一塊Buffer,範例使用的是keyState,256是鍵盤上所有的鍵的大小,透過GetDeviceState()來取得所有按鍵的輸入狀態,取完之後如何取得要偵測的按鍵的狀態呢?可以參考
Keyboard Device Enumeration,用該列舉來做為索引值取得目前按鍵的狀態,如果不是0就代表按下。

參考資料

Capturing DirectX DirectInput Keyboard and Mouse Input
DirectInput
Keyboard Device Enumeration

相關文章

DirectInput的學習心得(2)

2018年9月3日 星期一

從Blender裡萃取模組資料(8)

從Blender裡萃取模組資料(8)

前言

  續前篇從Blender裡萃取模組資料(7),這次說明萃取Skeleton的動畫,在此做個紀錄。

內容

  如果使用前篇的範例來萃取Skeleton的動畫,應該會發現竟然完全沒問題,可以正常執行,但若仔細一看"Data path"的欄位,就會發現它有何不同了,就拿"location"來說,在Skeleton的動畫可能會是"pose.bones["spine"].location",其中的"spine"為該Bone的名稱。這裡有個陷阱!如果你直覺地用字串的split()來parse這個"Data path"的欄位的話會有問題,問題會發生在Bone的名稱是可以包含".",舉例來說如果Bone的名稱是"spine.001"的話,"Data path"就會變成"pose.bones["spine.001"].location",接著split()後就變成"[pose, bones["spine, 001, location]",明顯的,Bone的名稱會被切到,還必須注意Blender的名稱可以很自由,名稱裡可以出現單引號或雙引號,所以需要特製的Parse。

  接著用前篇的範例為基礎,製作出以下範例
import bpy

def ShowActionInfo(act):
  print('animation name',act.name)
  for curve in act.fcurves:
    print('Data path:',curve.data_path,' ',end='')
    print('')
    if curve.data_path == 'location':
        print("Data from transform.location")
    elif curve.data_path == 'rotation_euler':
        print("Data from transform.rotation_euler")
    elif curve.data_path == 'rotation_euler':
        print("Data from transform.rotation_quaternion")
    elif curve.data_path == 'scale':
        print("Data from transform.scale")
    elif curve.data_path[:12] == 'pose.bones["':
        namelastIndex = curve.data_path.rfind('"')
        if namelastIndex >= 0:
            boneName = curve.data_path[12:namelastIndex]
            propertyNameIndex = curve.data_path.rfind('.')
            if propertyNameIndex >= 0:
                propertyName = curve.data_path[ (propertyNameIndex+1):]
                if propertyName == 'location':
                    print('Bone:',boneName,' Property:transform.location')
                elif propertyName == 'rotation_euler':
                    print('Bone:',boneName,' Property:transform.rotation_euler')
                elif propertyName == 'rotation_quaternion':
                    print('Bone:',boneName,' Property:transform.rotation_quaternion')
                elif propertyName == 'scale':
                    print('Bone:',boneName,' Property:transform.scale')
                else:
                    print('Unknown property:',propertyName)
            else:
                print('Parse skeleton property error!')
        else:
            print('Parse skeleton bone name error!')
    else:
        print("Unknown data path!")
    print('Array index:',curve.array_index,' ',end='')
    print('')
    for keyFrame in curve.keyframe_points:
      print('Key time:',keyFrame.co[0],' ',end='')
      print('Key value:',keyFrame.co[1],' ',end='')
      print('')
#
def ShowAnimationInfo(obj):
  if obj.animation_data == None:
    print("No animation data in object!")
    return
  #Check actived action...
  actionCon = 0
  if obj.animation_data.action != None:
    ShowActionInfo(obj.animation_data.action)
    actionCon+=1
  #Check action in NLA tracks...
  for track in obj.animation_data.nla_tracks:
    for strip in track.strips:
      ShowActionInfo(strip.action)
      actionCon +=1
  print("Animation amount:",actionCon )
#
tagObj = bpy.data.objects['metarig']
ShowAnimationInfo(tagObj )

可以看到ShowActionInfo()裡多了非常多的if判斷,目前沒找到比較簡易的寫法,目前的作法以直觀與好懂為原則, 所以程式碼相當長。Bone的名稱如之前所說由於編輯器可以相當自由命名,所以用rfind()來找,property的部分也 是使用rfind()來找也請注意。

參考資料

Blender Documentation Contents

相關文章

從Blender裡萃取模組資料(7)