巴哈姆特
官網
前一篇是Direct3D,這一篇是移植到Linux,要把shadow map用OpenGL再寫一份。
這篇嘗試一個做法,不同程式語言用不同邊框。
C++的引擎本體:艾莉兒的顏色,粉紅色。
shader:要兩個電子妖精合作,用黃色。
本篇沒出現鈷寶負責的輔助工具,以後如果有就是鈷寶的顏色:淺藍色。
同時發在巴哈姆特
巴哈姆特不能修改表格框線顏色,只好改用底色。
一代Windows和Linux都是用OpenGL,為何二代Windows版要改用D3D11?因為隨著二代採用更進階的OpenGL功能,發現Windows版的OpenGL驅動程式通常寫得比較爛,像是效能比較差、同一個晶片有些功能D3D有但OpenGL沒有,可能OpenGL用的人比較少所以顯卡廠商就沒有認真寫驅動程式。而且Windows Phone和Windows on ARM只能使用D3D,以後開發這些平台的程式遲早要碰D3D。
步驟大致如下
- 建立畫shadow map用的framebuffer。
- 新增一個sampler物件,用來讀取shadow map。
- 修改計算矩陣的方式。
- 變更cull和depth test狀態,D3D11是套用物件一次設定一組狀態,OpenGL是呼叫函式一項一項設定。
//設成只畫背面、開啟depth test
glEnable(GL_CULL_FACE);
glCullFace(GL_FRONT);
glDepthMask(GL_TRUE);
glEnable(GL_DEPTH_TEST); - 一個shader要使用兩張貼圖時要呼叫glActiveTexture()變更目前的slot,這是筆者首次在一個shader裡用兩張貼圖。
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, shadowMapTexture);
glActiveTexture(GL_TEXTURE0);
//之後畫模型時將模型的貼圖放在slot 0 - 畫模型要準備三組shader:不畫影子的、畫shadow map的、用shadow map畫影子的。把以前寫的shader也重整一下。
再來把DirectX的shader語言:HLSL改寫成OpenGL的shader語言:GLSL。
這一步不難,HLSL和GLSL其實很多地方可以一對一對應。
float → float
float4 → vec4
線性內插 lerp() → mix()
很多函式都同名,如normalize、dot、clamp。
float4x3 → mat3x4
矩陣的行數和列數寫法相反。
mul(float4, float4x3) → vec4*mat3x4
向量與矩陣相乘HLSL必須用mul函式,GLSL可以用乘號。
兩者差別較大的地方是程式載入shader的方式。
第一點是HLSL沒有限定程式進入點的名稱,是在編譯shader時指定,但是GLSL規定叫做main(),所以把多個shader寫在同一個檔案裡有名稱相衝的問題。
第二點是HLSL是先用SDK附的工具——fxc.exe將程式碼編譯成byte code(中間碼),再把byte code給顯卡驅動程式,遊戲只要附帶byte code。但GLSL要把程式碼附在遊戲裡,執行遊戲時叫驅動程式即時編譯。
雖然OpenGL後來也開發出自己的byte code格式——SPIR-V,但目前還有很多顯卡沒支援。
這點我用自製的輔助工具型電子妖精——鈷寶解決。
鈷寶:好……。(開始包shader)
花點工夫做個工具,把編譯、打包、壓縮、以及遊戲本體載入shader自動化,之後就輕鬆了。
————————
到此為止一切都很順利,沒想到又在一個地方卡關:畫模型+影子時畫不出任何東西,但切換成不畫影子的模式就正常。
鈷寶:那個……,shader坐標計算……有錯。
艾莉兒:sampler物件也沒設對。
如前一篇所說,vertex shader要算出頂點在shadow map裡的位置,即shadow map framebuffer的螢幕坐標,再給pixel/fragment shader使用。
由於兩者對螢幕坐標的定義不一樣,pixel shader裡把螢幕坐標轉換到貼圖坐標的算式不同。
//要先除以w float3 shadowMapPos = IN.shadowMapPos.xyz/IN.shadowMapPos.w; //此時xy=-1~1,轉換成0~1且y要反向 shadowMapPos.xy = shadowMapPos.xy*float2(0.5,-0.5) + 0.5; |
//要先除以w vec3 shadowMapPos = varShadowMapPos.xyz/varShadowMapPos.w; //此時xyz=-1~1,轉換成0~1,跟D3D不同的是y不需要反向 shadowMapPos = shadowMapPos*0.5 + 0.5; |
OpenGL的Y不需要反向是因為我故意讓貼圖倒立,詳細原因看這篇:【程式】倒立的OpenGL貼圖坐標
還有忘了先呼叫glUseProgram()再設定uniform變數。glGetUniformLocation()有個programID參數,用這個參數指定要操作的program,但是glUniformi()是用glUseProgram()指定要操作的program。
//programID代表OpenGL的program物件 glUseProgram(programID); int location=glGetUniformLocation(programID, "shadowMapSampler"); glUniform1i(location, 1); |
————————
但修正這些錯誤後變成模型畫得出來,看不到影子。
艾莉兒:想辦法把GPU內部資料秀出來看看吧。
實驗一,把shadow map的貼圖坐標顯示出來。
在fragment shader裡這樣寫。
//Direct3D float3 shadowMapPos=IN.shadowMapPos.xyz/IN.shadowMapPos.w; shadowMapPos.xy = shadowMapPos.xy*float2(0.5,-0.5)+0.5; return float4(shadowMapPos.xyz,1); //OpenGL vec3 shadowMapPos=varShadowMapPos.xyz/varShadowMapPos.w; shadowMapPos = shadowMapPos*0.5+0.5; gl_FragColor=vec4(shadowMapPos.xyz,1); |
(暫時到Windows開發機上工作)
艾莉兒:弄好了,D3D11是這樣。
(再回到Linux開發機)
艾莉兒:OpenGL是這樣。
看起來一片藍+紫看不出什麼東西,但重點是兩張圖一樣,可見坐標計算是對的。
鈷寶:shadow map呢?
實驗二,顯示shadow map本身。
//Direct3D float3 shadowMapPos=IN.shadowMapPos.xyz/IN.shadowMapPos.w; shadowMapPos.xy = shadowMapPos.xy*float2(0.5,-0.5)+0.5; float4 depth = shadowMap1.Sample(defaultSampler, shadowMapPos.xy); return float4(depth.rrr,1); //OpenGL vec3 shadowMapPos=varShadowMapPos.xyz/varShadowMapPos.w; shadowMapPos = shadowMapPos*0.5+0.5; vec4 depth = texture(defaultSampler2, shadowMapPos.xy); gl_FragColor=vec4(depth.rrr,1); |
艾莉兒:D3D11。
(再到Linux)
艾莉兒:OpenGL。
D3D可以正確顯示shadow map,但OpenGL是一片黑。
艾莉兒:主人等一下,有些要注意的地方有做對嗎?
艾莉兒:我看有哪些……,Z buffer通常都建立成renderbuffer,但想在shader讀取Z buffer的話,Z buffer要建立成texture。
艾莉兒:shader裡的sampler必須是sampler2D型態,不能是sampler2DShadow型態。
鈷寶:還有……貼圖和sampler物件,bind不能弄錯。
艾莉兒:還有畫之前有沒有用glClear(GL_DEPTH_BUFFER_BIT)清除Z buffer?
再看一下code,這些都有注意到了,為了做這個實驗在shader裡宣告了第三個sampler物件,使用普通sampler讀取Z buffer而不是shadow sampler。
還有用glCheckFramebufferStatus()檢查framebuffer狀態,確定只有Z buffer沒有color buffer的framebuffer物件是可以使用的,不會被系統擋下來。
另外發現一件怪事,在Linux看實驗一的圖顏色會變這樣。
上圖是GIMP,用Korora預設的看圖軟體Gwenview也一樣,但用Firefox開圖檔的話顏色正常,且GIMP的顏色挑選器可以取得正確的顏色。
把GIMP的color management功能關閉顏色就對了,可見問題在color management,但是Gwenview要怎麼關閉color management?查了一下……,好像沒辦法,是寫死在程式裡。
發現一個很蠢的錯誤,算shadow map的FOV時忘了把角度換算成弧度,所以投影計算錯了,下面才是正確的shadow map坐標和shadow map。
但是修正角度後OpenGL還是畫不出shadow map。
艾莉兒:主人,應該有兩種可能,shadow map根本沒被畫出來,或是shadow map有畫出來但shader讀不到。
鈷寶:找到這個,要不要用?
apitrace https://apitrace.github.io/
特別找了一個D3D和OpenGL的debug工具——apitrace來用,確定Z pass這一步沒問題,shadow map有畫出來,問題出在shader讀不到貼圖。
也試過另一個debug工具——RenderDoc,這個似乎比較有名,但我用的時候常常當掉,不知是RenderDoc本身的bug、Korora套件庫收錄的版本有問題、還是要debug的程式要做什麼特別處理。
艾莉兒、鈷寶,換個方法吧。
解數學難題的時候,常用方法是把問題簡化看會得到什麼解,再增加變因一步步還原成原來的題目。決定先不要用整個引擎測試,寫個簡單的程式,只做「設法讓Z buffer有變化→把Z buffer畫到另一個framebuffer上」,坐標是程式直接給-1~1的螢幕坐標,把坐標轉換也省了。
————————
總算查出原因,以上過程花了兩天。
//這樣寫會錯 glGenSamplers(1, &samplerObj); glSamplerParameteri(samplerObj, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glSamplerParameteri(samplerObj, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST); …… glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_2D, textureID); //解法一 glGenSamplers(1, &samplerObj); glSamplerParameteri(samplerObj, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glSamplerParameteri(samplerObj, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST); …… glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_2D, textureID); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); //解法二 glGenSamplers(1, &samplerObj); glSamplerParameteri(samplerObj, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glSamplerParameteri(samplerObj, GL_TEXTURE_MIN_FILTER, GL_LINEAR); …… glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_2D, textureID); |
貼圖的GL_TEXTURE_MAX_LEVEL屬性預設值是1000,也就是必須把最大到最小的mipmap都準備好才能使用貼圖。
解法一是把GL_TEXTURE_MAX_LEVEL設成0,告訴驅動程式只有原始大小,沒有縮小的mipmap。
設成1代表有「原始大小+縮小成1/2」共兩層,依此類推。
解法二是把內插方式設成GL_LINEAR,確實地叫OpenGL不要用mipmap。
這個錯誤難找出來是因為用glGetError()取得錯誤碼也是傳回0,代表沒有error。
這裡反鋸齒設定用OpenGL 3.3新增的sampler物件功能,較早的版本是把反鋸齒設定視為貼圖的屬性,用glTexParameteri()設定。
——結論:產生貼圖之後一定要呼叫 glTexParameteri(target, GL_TEXTURE_MAX_LEVEL, level) 設定mipmap層數,即使沒使用mipmap也一樣。
艾莉兒:快看,總算畫出來了。
跟前一篇不是同一個程式故看起來有些不同。
離鏡頭較近的部分投射不出影子(如Patchouli的腳附近),因為光源位置設得很近,讓有些部分在shadow map外面,不過總之OpenGL的shadow map可以用了。
決定再做一個東西:參考這篇用cubemap實做shadow map。
https://learnopengl.com/Advanced-Lighting/Shadows/Point-Shadows
其實目前只能在測試程式看到影子,還沒辦法在遊戲裡正確畫出影子。
前一篇進度文提過,使用shadow map要注意shadow map在3D空間裡的位置,沒被shadow map涵蓋的物體就畫不出影子。本遊戲是點光源且因為光源位置的關係,照射角度有點大,要建立好幾個shadow map才能涵蓋所有可視範圍(如下面俯視圖),這樣C++建立物件,還有shader裡讀取shadow map很麻煩。
光源是點光源的時候,用cubemap實作shadow map是最不用煩惱位置的解法,可以涵蓋全方向,雖然Cyber Sprite 2光只朝一個方向照而不會用到四面八方,但現在做好的話以後做其他3D遊戲也用得著。
介紹一下cubemap的概念:這是一種特別格式的貼圖,正如其名有六個面,假想有個正方體以原點為中心,六個面分別是六張貼圖,可能來自圖檔或即時繪製產生。
使用左手坐標系,上圖的-X、-Y、+Z面在背後。
讀取平面貼圖時,貼圖坐標要給兩個軸決定讀取哪個位置的像素,數值在0和1之間。
讀取cubemap則要給三個軸,數值範圍不限,把貼圖坐標視為3D空間裡的點,將此點與原點連接會與正方體有一個交點,讀取正方體表面此位置的像素。
它的應用之一是環境貼圖(站在中央,往四面八方看到的景色),點光源是從中央往四周照射,也可以用cubemap記錄點與光源的距離。
————————
這還是我第一次用cubemap,而且是即時繪製,除了貼圖物件以外還要建立framebuffer物件。
保持冷靜,一步一步解題目,先看使用cubemap要準備哪些東西。
艾莉兒:我查一下……,產生貼圖的時候ArraySize和MiscFlags屬性要特別設。
其他屬性跟平面貼圖一樣,就寬高和格式之類的。
D3D11_TEXTURE2D_DESC td; td.ArraySize = 6; td.MiscFlags = D3D11_RESOURCE_MISC_TEXTURECUBE; td.Format = DXGI_FORMAT_R16_TYPELESS; …… //之後用CreateTexture2D()建立ID3D11Texture2D物件 |
有測試過,本遊戲的3D場景不是很大,16 bit的depth buffer就夠用,不需要用到24或32 bit。
艾莉兒:然後,shader resource物件建一個就好了,但是depth stencil物件要建六個。
//要建立這些物件 ID3D11ShaderResourceView* srView; ID3D11DepthStencilView* dsView[6]; ID3D11DepthStencilView** dsViewPtr=dsView; //texture是已經建好的ID3D11Texture2D物件 D3D11_SHADER_RESOURCE_VIEW_DESC srDesc; ZeroMemory(&srDesc, sizeof(D3D11_SHADER_RESOURCE_VIEW_DESC)); srDesc.Format=DXGI_FORMAT_R16_UNORM; srDesc.ViewDimension=D3D11_SRV_DIMENSION_TEXTURECUBE; srDesc.TextureCube.MipLevels=1; device->CreateShaderResourceView(texture, &srDesc, &srView); D3D11_DEPTH_STENCIL_VIEW_DESC dsDesc; ZeroMemory(&dsDesc, sizeof(D3D11_DEPTH_STENCIL_VIEW_DESC)); dsDesc.Format=DXGI_FORMAT_D16_UNORM; dsDesc.ViewDimension=D3D11_DSV_DIMENSION_TEXTURE2DARRAY; dsDesc.Texture2DArray.ArraySize=1; //用迴圈建立六個depth stencil view for(int i=0;i<6;i++){ dsDesc.Texture2DArray.FirstArraySlice=i; device->CreateDepthStencilView(texture, &dsDesc, dsViewPtr); dsViewPtr++; } |
分別算六次旋轉矩陣,把鏡頭面對+X、-X、+Y……這六個方向,套用六個depth stencil view。
上面那篇教學使用geometry shader,畫一次場景就可以更新六個面,但本遊戲不使用geometry shader,要跑迴圈畫六次。
float rotMatix[9]; //暫存的旋轉矩陣 for(int i=0;i<6;i++){ //套用depth stencil view context->OMSetRenderTargets(0, NULL, dsView[i]); context->ClearDepthStencilView(dsView[i], 0.0, DEPTH_CLEAR_VALUE,0); //計算旋轉矩陣,這一步還未完成 oneCubeFace(rotMatix, this->rotTr, FACE_DIR[i],FACE_TOP_DIR[i]); //用uniform buffer把shadow map位置傳給shader PerSceneUniform* data=(PerSceneUniform*)mapUniformBuffer( sppl->perSceneUniform, sizeof(SP3DCamera)); this->calcViewProj(rotMatix, &data->camera); unmapUniformBuffer(sppl->perSceneUniform); //畫出物件 ListIter iter; for(iter=objList->begin();iter!=objList->end();iter=iter->next()){ SP3DObjBase* obj=(SP3DObjBase*)iter->data; if(obj->flags & _3DOBJ_VISIBLE){ obj->draw(SP3DObjBase::DRAWTYPE_ZPASS); } } } |
本遊戲的場景配置並不需要六個面都畫,或許可以用個flag設定哪些面不用畫,省略一些計算。
計算旋轉矩陣嘛,雖然之前已經把3D的公式都研究出來但還是有點麻煩,先只畫一面看看(把for迴圈暫時拿掉)。
艾莉兒:更新好cubemap後,用cubemap畫圖的時候跟平面貼圖一樣,呼叫context->PSSetShaderResources()。
這個比較容易。
context->PSSetShaderResources(2,1, &srView); |
再來修改shader,鈷寶,看一下要準備什麼。
鈷寶:嗯……,要宣告一個TextureCube變數。
好,在宣告貼圖的地方加一行。
Texture2D<float4> texture1:register(t0); //一般貼圖 Texture2D<float> shadowMap1:register(t1); //平面shadow map TextureCube<float> cubeShadowMap1:register(t2); //cube shadow map |
鈷寶:sampler……,不用加新的。
鈷寶:讀取貼圖嘛……,只要貼圖是TextureCube型態,會自動判斷。
上面說過cubemap貼圖坐標要給三個軸,讀取貼圖的函式「texture物件.SampleCmpLevelZero()」,只要貼圖是TextureCube型態,就會使用貼圖坐標是三維向量的版本,平面貼圖則是貼圖坐標是二維。
不過Z buffer裡儲存的距離,cubemap和平面貼圖計算法不一樣,大概是這樣寫吧。
vertex shader
//float3 worldPos為頂點在世界坐標系的位置 OUT.shadowMapPos.xyz=mul(float4(worldPos,1), shadowMap.viewMatrix); float3 absValue=abs(OUT.shadowMapPos.xyz); float maxComponent = max(absValue.x, max(absValue.y, absValue.z)); OUT.shadowMapPos.w = maxComponent*shadowMap.projCoef.z+ shadowMap.projCoef.w; //shadowMapPos.xyz=鏡頭坐標系,w=要跟shadow map比較的值 |
//shadowMapPos是vertex shader算出來,像素在shadow map裡的位置 //對照:讀取平面shadow map的方法 float3 shadowMapPos=shadowMapPos.xyz/shadowMapPos.w; shadowMapPos.xy= shadowMapPos.xy*float2(0.5,-0.5) + 0.5; //SampleCmpLevelZero參數為(sampler, 貼圖坐標, 要跟shadow map比較的值) float notInShadow=shadowMap1.SampleCmpLevelZero( shadowMapSampler, shadowMapPos.xy, shadowMapPos.z); //cubemap要這樣寫 float3 absValue=abs(shadowMapPos.xyz); float maxComponent = max(absValue.x, max(absValue.y, absValue.z)); float notInShadow=cubeShadowMap1.SampleCmpLevelZero( shadowMapSampler, shadowMapPos.xyz, shadowMapPos.w/maxComponent); |
寫了一大堆而且沒有中途測試部分功能,也不知道這樣寫對不對。
好吧,艾莉兒、鈷寶,先試試看吧。
結果,只有二樓地板,還有天花板的一小部分有影子。
艾莉兒:果然沒那麼簡單……。
不過物件都有成功建立,表示產生物件的參數有填對。
做到這裡覺得還是先研究一下cubemap的性質比較好,不然一直是霧裡摸索,像是cubemap坐標、2D貼圖坐標、render target坐標這三者如何對應,找了幾篇說明都沒有寫得很清楚的。
(待續,寫到這裡已經很長了,分成另一篇)
沒有留言 :
張貼留言