2019-01-11

【進度】3D背景—實裝影子2、cube shadow map 1

前一篇在此
巴哈姆特
官網

前一篇是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;
Direct3D經過矩陣變換後Z坐標是0~1,可以直接跟shadow map裡的數值比較,只有xy需要-1~1到0~1的轉換。

//要先除以w
vec3 shadowMapPos = varShadowMapPos.xyz/varShadowMapPos.w;
//此時xyz=-1~1,轉換成0~1,跟D3D不同的是y不需要反向
shadowMapPos = shadowMapPos*0.5 + 0.5;
OpenGL經過矩陣變換後Z坐標是-1~1,xyz都要轉換到0~1的範圍,我把這個算式寫錯。
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);
這樣顏色的R分量是X坐標,G是Y坐標,B是與光源的距離(非線性)。

(暫時到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);
(跑到Windows)
艾莉兒: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);
內插方式如果設定成有mipmap的,如上面的GL_LINEAR_MIPMAP_NEAREST,貼圖物件必須把mipmap設定完整,shader才能正常讀取貼圖。
貼圖的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物件
此外因為要把depth buffer當成貼圖使用,Format必須設成TYPELESS而不是UNORM或FLOAT。
有測試過,本遊戲的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++;
}
艾莉兒:再來,更新cubemap的時候要把場景畫六次,一次畫一個面。
分別算六次旋轉矩陣,把鏡頭面對+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);
    }
  }
}
這裡有些是自訂的函式和常數,不是D3D11定義的。
本遊戲的場景配置並不需要六個面都畫,或許可以用個flag設定哪些面不用畫,省略一些計算。
計算旋轉矩陣嘛,雖然之前已經把3D的公式都研究出來但還是有點麻煩,先只畫一面看看(把for迴圈暫時拿掉)。

艾莉兒:更新好cubemap後,用cubemap畫圖的時候跟平面貼圖一樣,呼叫context->PSSetShaderResources()。

這個比較容易。
context->PSSetShaderResources(2,1, &srView);
第一參數是shader裡的slot,要跟shader裡旳宣告對應,0與1已經用在模型本身的貼圖和平面shadow map,所以這裡用2。

再來修改shader,鈷寶,看一下要準備什麼。

鈷寶:嗯……,要宣告一個TextureCube變數。

好,在宣告貼圖的地方加一行。
Texture2D<float4> texture1:register(t0);  //一般貼圖
Texture2D<float> shadowMap1:register(t1);  //平面shadow map
TextureCube<float> cubeShadowMap1:register(t2);  //cube shadow map
register(t2)就是對應到PSSetShaderResources()裡旳2。

鈷寶: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比較的值
pixel shader
//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);
平面shadow map要把xyz除以w,但cube shadow map要把w除以xyz的最大值。

寫了一大堆而且沒有中途測試部分功能,也不知道這樣寫對不對。
好吧,艾莉兒、鈷寶,先試試看吧。

結果,只有二樓地板,還有天花板的一小部分有影子。


艾莉兒:果然沒那麼簡單……。
不過物件都有成功建立,表示產生物件的參數有填對。

做到這裡覺得還是先研究一下cubemap的性質比較好,不然一直是霧裡摸索,像是cubemap坐標、2D貼圖坐標、render target坐標這三者如何對應,找了幾篇說明都沒有寫得很清楚的。

(待續,寫到這裡已經很長了,分成另一篇)

沒有留言 :

張貼留言