創作內容

3 GP

【進度】2月~3月的進度(2) & 鈷寶的工作介紹

作者:Shark│2019-03-25 21:05:55│巴幣:6│人氣:939
處理第一篇提到的第一個問題:支援flat shading。

這是進度文裡第一次鈷寶當主角,藉此順便介紹她的工作,因此雖然新增的只有一部分,把整個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) );
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
fileName參數是必要,後面的globalScale、offsetX、offsetY、offsetZ參數可省略。

再來是讀取參數。
#如果沒給命令列參數就用預設值
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)
這兩個參數是用來修改模型大小和原點位置,因為Blender的轉檔功能有錯誤,想修改模型不能在Blender裡改,只能靠我的鈷寶。

到這裡才開始處理OBJ檔。
首先,將整個檔案讀進來。
鈷寶:好……。
objFile = open(sys.argv[1],"rb")
objData = objFile.read()
objFile.close()
#此時objData是bytearray型態
(Python 3裡string和bytearray有別,學Python請務必搞清楚什麼時候該用哪一種,否則寫程式會碰到一些怪問題)

定義一個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)
(Python的特徵之一是縮排是語法的一部分,不可省略)
(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
艾莉兒:(把材質1的打光參數填入uniform buffer,告訴顯卡有156個index,然後context->DrawIndexed())
(把指標移動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能快速搜尋的特性可以找出重覆頂點……)
    def getVidTidNid(self):
      return (self.nid<<64)|(self.tid<<32)|self.vid
    (然後把不重覆的ObjVertex放進self.usedVertexList,新的index記在ObjVertex.newVertexIndex……)

    (雖然只要在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)
(struct的用法就請自己查文件。格式字串裡的<代表little endian,雖然這程式應該只會在x86的電腦跑,還是加上去比較嚴謹)
(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
位置、法線、貼圖坐標是8個連續的float,用array來包。

下一個是頂點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)
附帶一提,那篇PMX說明的這部分有錯,它說開始的int是「how many surfaces there are」,但應該是index數量,不是surface數量。

下一部分是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)
這裡就不說明material.packPmdData()了,請用struct和array自己實作。

最後還有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
鈷寶就會動手,輸出stage01.pmd,並且調整模型大小和原點位置。
縮放和位移是用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年問題。
引用網址:https://home.gamer.com.tw/TrackBack.php?sn=4337061
All rights reserved. 版權所有,保留一切權利

相關創作

同標籤作品搜尋:Cyber Sprite|遊戲製作|程式|Python

留言共 2 篇留言

ays.
想好奇問一下樓主 有沒有遇過怪怪的pmx模型? (像是有人改完怪怪的那種)

我有遇過某個模型,讀取他的資料顯示 Index Type 要用 4 btye, 但是其實應該要是 2 byte (不然會 parsing 到炸掉), 但是其他的模型又不用這樣額外處理的狀況... 想問問看有沒有甚麼想法XD

03-25 21:49

Shark
我沒遇過這種的。

照你說的情況應該是那個模型做錯了,可能作者用的軟體有問題。03-26 22:25
ays.
OK 我也是這樣想的 謝謝你

03-27 01:57

我要留言提醒:您尚未登入,請先登入再留言

3喜歡★shark0r 可決定是否刪除您的留言,請勿發表違反站規文字。

前一篇:【進度】2019年2月~... 後一篇:【進度】挑戰Blende...

追蹤私訊切換新版閱覽

作品資料夾

pjfl20180818自己
怎覺得被騷擾了看更多我要大聲說昨天15:46


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

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