這是進度文裡第一次鈷寶當主角,藉此順便介紹她的工作,因此雖然新增的只有一部分,把整個OBJ→PMD轉檔工具一併介紹了。
也因此這篇程式碼的量很多。
同時發在巴哈姆特
D3D和OpenGL能用的vertex buffer格式一個頂點只能配一個法線坐標,這樣算完vertex shader、內插後自然會是smooth shading,如下圖左(藍色短線是法線)。注意面上的顏色,有明暗變化使得面看起來不是平的。
如果想做成有稜有角如下圖右,正方體各頂點必須拆成3個相同位置不同法線的頂點,畫共點的3個面時分別用這3個頂點。
3D軟體可能會用別的方法設定要不要平滑化,內部也要轉換成D3D和OpenGL能吃的格式才能顯示在螢幕上。
由於本遊戲的背景是有稜有角的物體居多,如果引擎原生支援flat shading就可以少拆一些頂點,減少頂點數量。
本遊戲顯示3D模型的流程是「鈷寶將OBJ轉換成PMD或PMX→艾莉兒讀取模型→艾莉兒將模型給顯卡繪製」,從右邊開始做。
首先是shader裡用法線計算打光的部分。
艾莉兒:以前無意間試出來過,滿簡單的。
pixel shader裡法線不要用頂點內插的值,而是用這個就行了。
float3 normal = cross( ddx(worldPos), ddy(worldPos) ); |
ddx和ddy是shader內建函式,ddx(worldPos)和ddy(worldPos)可以得到平貼在polygon上的兩個向量,將兩者外積就可以得到垂直於polygon的向量,也就是法線。
然後模型檔裡要記錄哪些面用flat shading,但PMD和PMX原本不支援flat shading,該怎麼做呢?
艾莉兒:PMD裡把法線設成(0,0,0)呢?我看到這個值就做特殊處理。
正常法線不可能用這個值,用(0,0,0)做特殊標記確實是個方法。
艾莉兒:可是其他軟體能不能讀這樣的PMD和PMX?
目前不想管了,做遊戲覺得現有的東西不夠用,於是自行改造是常有的事,如果拘泥在PMD和PMX的規格那很多事都不能做。
先小試一下,這把椅子本來要用smooth shading,用flat shading畫畫看。
艾莉兒:好,動手!
左邊是smooth shading,右邊是flat shading。
艾莉兒:很好,這樣我讀模型和丟給顯卡繪製的部分完成了。
不過鈷寶那邊怎麼辦呢?
之後鈷寶的部分才是難關,以前說過因為Blender讀取OBJ的功能有問題,我只好自己寫個工具轉換。
要檢查哪些頂點是flat shading,把這些頂點的法線設成特殊的值,再輸出PMD和PMX。
這裡開始主角是鈷寶了,這是第一次詳細介紹鈷寶的工作,以前都是艾莉兒的戲份居多。
鈷寶:各……各位好,鈷寶……要上場了。
筆者做遊戲到現在已經開發很多個輔助工具,大致有四種:
第一種是其他軟體的plugin,如Inkscape和LibreOffice Calc。
另一種是獨立的轉檔程式,讀取檔案→整理資料→寫入成另一種格式。
第三種是自動產生C++程式碼的工具,用Python寫個程式輸出文字檔,也就是用程式寫程式。引擎裡編譯、打包shader是這樣做的。
第四種是需要操作底層(例如修改圖裡的像素),鈷寶做不到,必須艾莉兒來做(寫個C++程式)。貼圖加邊框的工具是這一種。
通常只有命令列而沒做GUI。做GUI很費工,而且輔助工具只要作者會用就行了,不用考慮其他使用者。
本篇的OBJ→PMD轉檔工具屬於第二種。
主要用解譯型語言寫。比較不要求效能,在作者的電腦上跑得動就行了,不用考慮在不同規格的電腦執行,跟艾莉兒必須重視速度和輕量化很不一樣。
鈷寶的工作比較重視準確度而不要求速度:要搞懂檔案裡哪個byte是做什麼用的、把資料關聯性重整、善用腳本語言內建的字串處理和資料結構解決問題。
OBJ的格式說明,維基百科就寫得很詳細了。
Wavefront .obj file
因為是純文字檔,有不懂的地方可以用文字編輯器看檔案內容。
整體結構大致是用數個陣列記錄所有位置、貼圖、法線坐標,然後頂點記錄陣列裡的index,從陣列取出坐標。
它的結構不能直接給D3D和OpenGL用,必須轉換成其他格式。
命令列程式要用命令列參數控制,所以要知道如何取得參數。但第一步並不是立刻寫主要邏輯。
鈷寶,我寫了個簡易說明,把它留著,以後我忘記時提醒我。
鈷寶:嗯……。
(本小屋第一個藍框程式,藍框代表輔助工具。使用的語言是Python 3)
#!/usr/bin/python3 #coding:utf-8 import sys #取得命令列參數 import os.path #使用os.path解析路徑 import math from collections import OrderedDict import struct #這兩個會在輸出PMD,PMX時用到 import array if len(sys.argv)<2: scriptName=os.path.basename(sys.argv[0]) #取得目前Python檔的名稱 print("usage:") print("%s fileName [globalScale] [offsetX] [offsetY] [offsetZ]" % (scriptName,)) print("offset will be added to each vertex coordinate before scaling") sys.exit(0) |
將程式儲存成objtopmd.py。如果不加參數執行程式會跳這個訊息,這樣萬一忘記用法也可以隨時查看。
D:\>objtopmd.py usage: objtopmd.py fileName [globalScale] [offsetX] [offsetY] [offsetZ] offset will be added to each vertex coordinate before scaling |
再來是讀取參數。
#如果沒給命令列參數就用預設值 globalScale=1.0 globalOffset=[0,0,0] #如果參數有3個以上就設globalScale=第3參數 if len(sys.argv)>=3: globalScale=float(sys.argv[2]) #globalOffset是第4~6個參數 if len(sys.argv)>=4: for i,arg in enumerate(sys.argv[3:6]): globalOffset[i]=float(arg) |
到這裡才開始處理OBJ檔。
首先,將整個檔案讀進來。
鈷寶:好……。
objFile = open(sys.argv[1],"rb") objData = objFile.read() objFile.close() #此時objData是bytearray型態 |
定義一個class代表OBJ檔,把OBJ的內容轉換成Python的資料結構。
把objData分行,然後每行裡面用空格分隔。
鈷寶:嗯……。
(Python有內建函式可做bytearray處埋)
class ObjFile: def __init__(self, rawData): #成員變數 #OBJ的index是從1開始,事先插入一個0可以不用每次都把index減1 self.vertexList=[0,] self.texCoordList=[0,] self.normalList=[0,] #groupDict要記錄插入的順序,所以用OrderedDict self.groupDict=OrderedDict() self.materialDict={} objLines = rawData.splitlines() nowGroup = None #目前在處理的group #還有一些其他處理,在此省略…… for line in objLines: line=line.strip() #跳過空行和註解 if len(line)==0: continue if line[0]==ord("#"): #comment continue words=line.split() |
再來根據words的第一個元素做不同處理
鈷寶:……。(工作中)
#Python沒有switch,只能用連續的if代替 #不然可能要用key是字串,value是函式的dict if words[0]==b"v": #位置 self.vertexList.append(makeFloatTuple(words)) elif words[0]==b"vt": #貼圖坐標 self.texCoordList.append(makeFloatTuple(words)) elif words[0]==b"vn": #法線 normal=normalize(makeFloatTuple(words)) self.normalList.append(normal) #makeFloatTuple和normalize是我自己寫的函式,不是Python內建 #把面和頂點按照material分組,因為PMD,PMX是按照material分組 elif words[0]==b"usemtl": #下一個material #檢查有沒有相同的material nowGroup=self.groupDict.get(words[1]) if nowGroup==None: nowGroup=ObjGroup(words[1]) self.groupDict[words[1]]=nowGroup elif words[0]==b"f": #面 nowGroup.addFace(words, self) #之後是其他的資料…… #mtllib、s和g不會用到,忽略 #其他method定義…… #定義好class後,建立物件 objContent=ObjFile(objData) |
(b"v"、b"vt"這些前面加個b是因為它是bytearray不是string,再說一次,學Python請務必分清楚string和bytearray的差別)
然後讀取.mtl檔,仿照上面的方法解析字串,取得各材質的貼圖和打光參數。
鈷寶:……。(工作中)
鈷寶:主人,弄好了,資料結構……是這樣。
#有這些class ObjFile #整個OBJ檔 ObjGroup #同一個material的面 ObjFace #一面可能有3或4個頂點 ObjVertex Material #mtl檔的資料 #--以下是ObjFile裡的資料 #坐標 ObjFile.vertexList[] #list of float3 tuple ObjFile.texCoordList[] #list of float2 tuple ObjFile.normalList[] #list of float3 tuple #polygon資料,以階層式儲存 ObjFile.groupDict{} #material name -> ObjGroup ObjGroup.faceList[] #list of ObjFace ObjFace.vertices[] #list of ObjVertex #多個面共點的話,每個面有各自的ObjVertex ObjVertex.vid #在坐標list裡面的index ObjVertex.tid ObjVertex.nid ObjVertex.vCoord #=vertexList[vid],事先把陣列裡的坐標取出來 ObjVertex.tCoord ObjVertex.nCoord #material資料 ObjFile.materialDict{} #material name -> Material Material.name Material.diffuse Material.dissove #alpha Material.ambiane Material.specular Material.specularExp #specular強度 Material.map_Kd #貼圖檔名 |
讀取完下一步是整理。
要參照PMD,PMX的格式說明,這兩篇是我找到寫得最清楚的。
MikuMikuDance Wiki的PMD說明
github上有人寫的的PMX說明
看index和material的說明,想像一下把這樣的資料給艾莉兒後她要怎麼畫PMD,才能決定怎麼整理。
如果index數量是這樣。
材質1 | 156 |
材質2 | 156 |
材質3 | 144 |
(把指標移動156個uint16_t)
(把材質2填入uniform buffer,156個index,context->DrawIndexed())
…………
想好了,用這些變數儲存整理過的資料。
#這些要輸出到檔案,設成instance variable保存資料 self.usedMaterialList=[] self.usedVertexList=[] self.groupListByMaterial=[] self.textureNameDict=OrderedDict() #texture file name -> index self.totalIndexCount=0 #這些在函式結束後就不需要,設成區域變數 flatVertexDict={} usedVertexDict={} |
要做這幾件事
(這部分code很長,懶得寫了,要利用list和dict的特性暫存資料)
- 合併重複material。
把打光參數和貼圖相同的看成是同一個material。
鈷寶:(巡訪materialDict.values()……)
(然後找self.usedMaterialList有沒有相同的Material物件,沒有就放入這個list……)
因為material數量不多,在list裡搜尋也不會很慢。
- 統計各個material有幾個index。
用for巡訪各個ObjGroup、ObjFace統計頂點數量,同時把group放入self.groupListByMaterial,讓相同材質的group連續。
同時統計index總數totalIndexCount。
- 找出flat shading的頂點。
本篇的重點,為了解決「支援flat shading」要新增這一步,其他部分都是以前就寫好的。
判斷方法是,只要面的法線和頂點的法線同向,那這個頂點就是flat shading。
巡訪ObjGroup、ObjFace和ObjVertex,把每個頂點都做如下計算,用兩次外積:
a. AD cross AB,求出面的法線Fn。
b. 利用這個公式:|U cross V|=|U||V|sinθ。
Fn cross Vn,假如算出的向量是[x,y,z],計算「x*x+y*y+z*z」可推算兩條法線的夾角,小於一個值,譬如0.05就視為flat。
數學上外積=0才是同向,但是電腦算浮點數有誤差,要有裕度而不能寫成分毫不差等於0。
flat的頂點就把ObjVertex.nid設為0,法線設為(0,0,0),代表特殊處理。
A算完後把B,C,D也做同樣的檢查。
不過我還加了一個條件:「如果有數個頂點是相同位置和貼圖坐標,所有點都滿足條件才設為flat」,防止下圖不需要拆頂點的情況也拆開,反而增加頂點。
- 合併重覆頂點。
位置、貼圖、貼圖坐標、法線都相同的看成同一個頂點。要在「找出flat shading的頂點」之後做,因為該步驟會修改法線。
用usedVertexDict暫存資料。
鈷寶:(用這個當作key存入dict……,利用dict能快速搜尋的特性可以找出重覆頂點……)
(然後把不重覆的ObjVertex放進self.usedVertexList,新的index記在ObjVertex.newVertexIndex……)def getVidTidNid(self):
return (self.nid<<64)|(self.tid<<32)|self.vid
(雖然只要在class裡定義__hash__和__eq__就可以把class當作dict key,但我這裡沒有使用)
頂點數量比material多很多,用dict提升搜尋速率比較好。
我有查過Python的文件,Python 3的整數沒有範圍限制,所以左移64位元是做得到的。
可能Python內部會判斷,平常用機器原生型態儲存,遇到機器不能支援的大數字就改用陣列。
整理完後要輸出成PMD或PMX了,本篇以PMD為例,再貼一次格式說明。
MikuMikuDance Wiki的PMD說明
github上有人寫的的PMX說明
不是純文字而是binary資料,Python處理binary資料要用這兩個模組。
struct:用在包含不同資料型態的情況。
array:用在同一種資料型態重覆很多個的情況。
讀寫檔案、socket、與其他程式語言交換資料都會用到binary資料,這兩個是滿常用的模組。
先開啟輸出檔。
#用os.path模組分離路徑 #主檔名與obj相同,附檔名改成pmd outFileName=os.path.splitext(sys.argv[1])[0]+".pmd" outFile=open(outFileName,"wb") |
把寫入檔案的部分寫在class ObjFile裡。
我看看,PMD一開始是File header和Model header……,鈷寶,寫入開始的283 bytes。
我不會用到模型名稱和註解,全部填0。
class ObjFile: def writePmd(self, outFile, globalScale, globalOffset): PMD_HEADER=struct.pack("<3sf276s",b"Pmd",1.0,b"\0") outFile.write(PMD_HEADER) |
(globalScale和globalOffset留待之後傳給ObjVertex)
再來是vertex資料,要先寫入一個4 byte整數表示頂點數量。
寫入4 byte整數好像會用到很多次,寫個函式吧。
鈷寶:嗯……離開class ObjFile……新增一個函式……
#class ObjFile外面 def writeInt(file,value): file.write(struct.pack("<I",value)) |
鈷寶:回來class ObjFile……
對了,把輸出的頂點數量跟我報告。
鈷寶:呃……好。
print("output vertices", len(self.usedVertexList)) writeInt(outFile, len(self.usedVertexList)) for vertex in self.usedVertexList: outFile.write(vertex.packPmdData(globalScale, globalOffset)) |
vertex.packPmdData內容是這樣,命令列參數的globalScale和globalOffset就是用在這裡。
對了,追加一個把數值限制在0~1的函式,因為OBJ裡貼圖坐標可能會超出0~1的範圍。
def clamp(value): return max(min(value, 1.0), 0) class ObjVertex: def packPmdData(self, globalScale, globalOffset): #位置,用map()把tuple的所有元素套用相同函式 tempList=list(map(lambda x,offset:(x+offset)*globalScale, self.vCoord, globalOffset)) tempList[2]*=-1 #.obj用右手坐標系,把Z反向轉換成左手坐標系 tempArray=array.array("f",tempList) #法線 tempList=list(self.nCoord) tempList[2]*=-1 tempArray.fromlist(tempList) #貼圖坐標 tempList=list(self.tCoord[0:2]) tempList[0]=clamp(tempList[0]) #.obj的貼圖坐標是左下為(0,0),PMD是左上為(0,0),要修改Y坐標 tempList[1]=clamp(1.0-tempList[1]) tempArray.fromlist(tempList) boneData=struct.pack('<HHbb',0,0,0,0) #目前沒用到bone和edge return tempArray.tobytes()+boneData |
下一個是頂點index,先包一個int表示index數量,然後每個index是2 byte整數。
index要按照material分組,並且告訴我輸出幾個三角形。
鈷寶:……。(工作中)
#class ObjFace新增這些函式 class ObjFace: def packIndex(self): v0Index=self[0].newVertexIndex #頂點可能有3或4個,此迴圈可以把4個頂點的面拆成兩個三角形 for i in range(1,len(self)-1): data+=struct.pack('<HHH',v0Index, self[i+1].newVertexIndex, self[i].newVertexIndex) return data def __getitem__(self, key): #可以使用self[index]取得頂點 return self.vertices[key] def __len__(self): #可以使用len(self)取得頂點數量 return len(self.vertices) #class ObjFile內 print("output triangles", self.totalIndexCount//3) writeInt(outFile, self.totalIndexCount) for group in self.groupListByMaterial: for face in group.faceList: data=face.packIndex() outFile.write(data) |
下一部分是material。
美術給我的貼圖可能有實際沒用到的,順便幫我把用到的貼圖找出來。
鈷寶:……好。
writeInt(outFile, len(self.usedMaterialList)) materialNameList=[] for material in self.usedMaterialList: outFile.write(material.packPmdData()) materialNameList.append(material.map_Kd) #列出用到的貼圖,按檔名排序 materialNameList=sorted(materialNameList) print("\nused textures") print(materialNameList) |
最後還有6種資料:bone, IK, face morph, face morph name, bone group names, displayed bones
我目前沒用到,全部填0個。
outFile.write(struct.pack("<HHHBBI",0,0,0,0,0,0)) |
大功告成,關檔案。
outFile.close() |
至於這個工具的用法,如果原檔是stage01.obj和stage01.mtl,開命令列打這一行。
objtopmd.py stage01.obj 0.03453 -2.5 0 0 |
縮放和位移是用Blender開OBJ檔,人工調整求出來的,Blender讀OBJ檔雖然法線會錯,但頂點位置是對的。
接下來用PMDEditor、PMXEditor、或是叫艾莉兒開啟PMD檔,驗證轉檔是否正確。
這就是轉檔工具的大概流程,會用到的功能先是讀寫檔,然後看檔案類型用不同模組處理:binary檔用struct和array、XML用xml.dom.minidom模組、其他純文字用字串處埋method。
加上flat shading的功能,實測後確實有點效果,第一關背景的頂點從4000多個減到3236個,第2關背景從80000個減到60000個。
還要解第二個問題「Blender讀取OBJ和輸出PMD,PMX會出錯」,待續。
關於Linux發行版的題外話:最近看到Korora停止開發的消息,可能要把開發用的發行版換回Mint了。
Ubuntu和衍生的Mint有個問題:64位元作業系統不能裝32位元的開發用套件,而Fedora體系的可以同時裝32和64位元開發用套件,這是當時改用Korora的主因。由於一代程式是32位元,當初是靠著Korora才能製作一代外語版。
二代應該只會做64位元版了,32位元Linux有2038年問題。
沒有留言 :
張貼留言