創作內容

4 GP

【進度】3D背景—cube shadow map 2

作者:Shark│2019-01-21 07:12:43│贊助:106│人氣:328
續上篇,先研究cubemap的性質再做下一步。

前一篇:【進度】3D背景—實裝影子2、cube shadow map 1
巴哈姆特
官網

像解數學題目一樣,先把問題簡化看會變成怎麼樣,寫個較簡單的程式,只做建視窗、初始化Direct3D和cubemap,其他一概省略。

同時發在官網

實驗一,cubemap內容來自圖檔,圖檔坐標與cubemap坐標的關係。

艾莉兒:照以前的測試程式嗎?好。
用CreateWindow()建視窗、建立ID3D11Device、IDXGISwapChain、ID3D11DeviceContext物件……

(初始化程式碼很長,在此省略)
艾莉兒:然後建一個ID3D11Texture2D物件,照前一篇說的設成ArraySize=6,MiscFlags=D3D11_RESOURCE_MISC_TEXTURECUBE。

以下是完整的建立貼圖程式碼,比較特別的地方是,貼圖用程式自動產生,再傳給顯卡。
//貼圖大小
const int TEX_W=64;
const int TEX_H=64;
const int TEX_PIXEL_NUM=TEX_W*TEX_H;

//要建立的物件
ID3D11ShaderResourceView* cubeTexture;

//create cube texture
HRESULT hr;
D3D11_TEXTURE2D_DESC td;
td.Width=TEX_W;
td.Height=TEX_H;
td.MipLevels=1;
td.ArraySize=6; //single image or cubemap
td.Format=DXGI_FORMAT_B8G8R8A8_UNORM;
td.SampleDesc.Count=1; //multisample sample number
td.SampleDesc.Quality=0;
td.Usage=D3D11_USAGE_DEFAULT;
td.BindFlags=D3D11_BIND_SHADER_RESOURCE;
td.CPUAccessFlags = 0;
td.MiscFlags=D3D11_RESOURCE_MISC_TEXTURECUBE;
ID3D11Texture2D* texture;
hr=device->CreateTexture2D(&td, NULL, &texture);

const uint32_t CUBE_FACES[]={0xffff0000, 0xff800000,
  0xff00ff00, 0xff008000, 0xff0000ff,0xff000080};
//產生64×64 pixel的buffer
uint32_t colorBuffer[TEX_PIXEL_NUM];

for(int i=0;i<6;i++){
  for(int j=0;j<TEX_PIXEL_NUM;j++){
    colorBuffer[j]=CUBE_FACES[i];
  }
  colorBuffer[0]=0xffffffff;
  colorBuffer[1]=0xffffffff;
  colorBuffer[TEX_W]=0xffffffff;
  colorBuffer[TEX_W+1]=0xffffffff;
  colorBuffer[TEX_W-2]=0xff808080;
  colorBuffer[TEX_W-1]=0xff808080;
  colorBuffer[TEX_W*2-2]=0xff808080;
  colorBuffer[TEX_W*2-1]=0xff808080;
  colorBuffer[TEX_PIXEL_NUM-TEX_W-2]=0xff000000;
  colorBuffer[TEX_PIXEL_NUM-TEX_W-1]=0xff000000;
  colorBuffer[TEX_PIXEL_NUM-2]=0xff000000;
  colorBuffer[TEX_PIXEL_NUM-1]=0xff000000;

  context->UpdateSubresource(texture,i,NULL,colorBuffer, TEX_W*4, 0);
}

hr=device->CreateShaderResourceView(texture, NULL, &cubeTexture);
texture->Release(); //扣掉CreateTexture2D()的reference count
for迴圈裡做的事是,把cube六個面塗上不同顏色。
+X:紅
-X:暗紅
+Y:綠
-Y:暗綠
+Z:藍
-Z:暗藍
然後把colorBuffer裡左上角的點設成白色,右上角設成灰色,右下角設成黑色,藉此看出如果貼圖來自圖檔時,圖檔和cubemap的坐標怎麼對應。

shader就儘量寫得簡單,vertex shader只把坐標傳給pixel shader,pixel shader讀取cubemap並顯示在螢幕。
TextureCube<float4> cubeTexture1:register(t1);
SamplerState defaultSampler:register(s0);

struct DrawCubemapVsIn{
  float2 pos:P;
  float3 cubeTexCoord:T;
};

struct DrawCubemapVsOut{
  float4 pos:SV_Position;
  float3 cubeTexCoord:T;
};

void drawCubemapVS(in DrawCubeMapVsIn IN,out DrawCubeMapVsOut OUT){
  OUT.pos=float4(IN.pos,0,1);
  OUT.cubeTexCoord=IN.cubeTexCoord;
}

float4 drawCubemapPS(in DrawCubeMapVsOut IN): SV_Target{
  return cubeTexture1.Sample(defaultSampler, IN.cubeTexCoord);
}
鈷寶,編譯shader,這次不用把資料壓縮了,以簡化程式。
鈷寶:好……。

然後,畫六個正方形把cube各面顯示出來,頂點坐標用寫在程式裡的方式。
struct DrawCubeVertex{
  float pos[2]; //螢幕坐標
  float cubeTexCoord[3]; //cubemap貼圖坐標
};

const DrawCubeVertex DRAW_CUBEMAP_VERTEX[]={
  {-1,   0.25, 1, 1,-1}, //+X左邊
  {-1,  -0.25, 1,-1,-1},
  {-0.5, 0.25, 1, 1, 1}, //+X與+Z的邊
  {-0.5,-0.25, 1,-1, 1},
  {0, 0.25,  -1, 1,1}, //+Z與-X的邊
  {0,-0.25,  -1,-1,1},
  {0.5, 0.25, -1, 1,-1}, //-X與-Z的邊
  {0.5,-0.25, -1,-1,-1},
  {1, 0.25,  1, 1,-1}, //-Z右邊
  {1,-0.25,  1,-1,-1},

  {1,-0.25,  0,0,0}, //dummy
  {-1,0.75,  0,0,0},

  {-1, 0.75,  -1, 1,-1}, //+Y face
  {-1, 0.25,   1, 1,-1},
  {-0.5, 0.75,-1, 1, 1},
  {-0.5, 0.25, 1, 1,1},

  {-0.5, 0.25, 0,0,0}, //dummy
  {-1, -0.25,  0,0,0},

  {-1, -0.25,  1, -1,-1}, //-Y face
  {-1, -0.75, -1, -1,-1},
  {-0.5,-0.25, 1, -1, 1},
  {-0.5,-0.75,-1, -1, 1},
};
const int DRAW_CUBEMAP_VERTEX_NUM =
  sizeof(DRAW_CUBEMAP_VERTEX)/sizeof(DrawCubeVertex);

//幾何圖形設成triangle strip
畫物件要設定render target和viewport、把頂點上傳至buffer物件、設定貼圖(shader resource)、設定shader和vertex layout,要做的事滿多的,在此也省略。

結果
Direct3D,左手坐標系1

字不是測試程式畫的,是之後標上去的。

放大後可看到正方形邊緣隱約有雜色,例如-X面上邊緣混了一點綠色。
這是顯卡做反鋸齒時,把相鄰像素也拿來內插造成的,這一面跟+Y面相鄰就混入+Y面的綠色。


實驗二,cubemap內容是用framebuffer object繪製,framebuffer坐標與cubemap坐標的關係。

建立貼圖的程式改成這樣
//貼圖大小
const int TEX_W=64;
const int TEX_H=64;
const int TEX_PIXEL_NUM=TEX_W*TEX_H;

//要建立的物件
ID3D11ShaderResourceView* cubeTexture;
ID3D11RenderTargetView* cubeRenderTarget[6];

//create cube texture
HRESULT hr;
D3D11_TEXTURE2D_DESC td;
td.Width=TEX_W;
td.Height=TEX_H;
td.MipLevels=1;
td.ArraySize=6; //single image or cubemap
td.Format=DXGI_FORMAT_B8G8R8A8_UNORM;
td.SampleDesc.Count=1; //multisample sample number
td.SampleDesc.Quality=0;
td.Usage=D3D11_USAGE_DEFAULT;
  //BindFlags跟上面不一樣
td.BindFlags=D3D11_BIND_SHADER_RESOURCE|D3D11_BIND_RENDER_TARGET;
td.CPUAccessFlags = 0;
td.MiscFlags=D3D11_RESOURCE_MISC_TEXTURECUBE;
ID3D11Texture2D* texture;
hr=device->CreateTexture2D(&td, NULL, &texture);

D3D11_RENDER_TARGET_VIEW_DESC rtDesc;
rtDesc.Format=DXGI_FORMAT_B8G8R8A8_UNORM;
rtDesc.ViewDimension=D3D11_RTV_DIMENSION_TEXTURE2DARRAY;
rtDesc.Texture2DArray.MipSlice=0;
rtDesc.Texture2DArray.ArraySize=1;
//六面各建立一個render target view
for(int i=0;i<6;i++){
  rtDesc.Texture2DArray.FirstArraySlice=i;
  hr=device->CreateRenderTargetView(texture, &rtDesc, cubeRenderTarget+i);
}

hr=device->CreateShaderResourceView(texture, NULL, &cubeTexture);
texture->Release(); //扣掉CreateTexture2D()的reference count

更新cubemap的shader這樣寫
struct UpdateCubemapVsOut{
  float4 pos:SV_Position;
  float2 screenCoord:P;
};

void updateCubemapVS(in float2 pos:P, out UpdateCubemapVsOut OUT){
  //將-1~1變為0~1
  OUT.screenCoord=pos*0.5+0.5;
  OUT.pos=float4(pos,0,1);
}

float4 updateCubemapPS(in UpdateCubemapVsOut IN): SV_Target{
  return float4(IN.screenCoord.x, 0, IN.screenCoord.y, 1);
}
將framebuffer坐標直接變成R和B值,四個角落會塗上不同顏色。
(-1,-1) : 黑
(1,-1) : 紅
(-1,1) : 藍
(1,1) : 紫

頂點這樣設,一個正方形佔滿整個面。
const float UPDATE_CUBEMAP_VERTEX[]={
  -1,1, -1,-1, 1,1, 1,-1,
};
用相同的shader和頂點,跑迴圈套用六個render target view畫出六個面。

最後再套用實驗一的頂點坐標和shader,把cubemap顯示在畫面。
Direct3D,左手坐標系2


艾莉兒:OpenGL要不要也試一下?OpenGL的貼圖坐標定義是上下顛倒的,不知道對cubemap有沒有影響。
說得也是,兩位,到Linux開發機工作吧。

實驗一,建貼圖的程式碼是這樣
//貼圖大小
const int TEX_W=64;
const int TEX_H=64;
const int TEX_PIXEL_NUM=TEX_W*TEX_H;

//要建立的物件
uint32_t texture;

//create cube texture
glGenTextures(1,&texture);
glBindTexture(GL_TEXTURE_CUBE_MAP,texture);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAX_LEVEL, 0);

const int CUBE_FACES[]={0xffff0000, 0xff800000,
  0xff00ff00, 0xff008000, 0xff0000ff,0xff000080};
//產生64×64 pixel的buffer
uint32_t colorBuffer[TEX_PIXEL_NUM];

for(int i=0;i<6;i++){
  for(int j=0;j<TEX_PIXEL_NUM;j++){
    colorBuffer[j]=CUBE_FACES[i];
  }
  colorBuffer[0]=0xffffffff;
  colorBuffer[1]=0xffffffff;
  colorBuffer[TEX_W]=0xffffffff;
  colorBuffer[TEX_W+1]=0xffffffff;
  colorBuffer[TEX_W-2]=0xff808080;
  colorBuffer[TEX_W-1]=0xff808080;
  colorBuffer[TEX_W*2-2]=0xff808080;
  colorBuffer[TEX_W*2-1]=0xff808080;
  colorBuffer[TEX_PIXEL_NUM-TEX_W-2]=0xff000000;
  colorBuffer[TEX_PIXEL_NUM-TEX_W-1]=0xff000000;
  colorBuffer[TEX_PIXEL_NUM-2]=0xff000000;
  colorBuffer[TEX_PIXEL_NUM-1]=0xff000000;

  glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X+i, 0, GL_RGBA8,
    TEX_W, TEX_H, 0, GL_BGRA, GL_UNSIGNED_BYTE, colorBuffer);
}
glTexImage2D()第一參數要填以下的值
GL_TEXTURE_CUBE_MAP_POSITIVE_X
GL_TEXTURE_CUBE_MAP_NEGATIVE_X
GL_TEXTURE_CUBE_MAP_POSITIVE_Y
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y
GL_TEXTURE_CUBE_MAP_POSITIVE_Z
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z
但因為這六個剛好是連續整數,可以用「GL_TEXTURE_CUBE_MAP_POSITIVE_X+i」代替。

前一篇受過教訓,這次我沒忘了設定GL_TEXTURE_MAX_LEVEL了。

shader
//====common
//要用OpenGL 3.3的規格
//如果不加這行,會視為最早的1.0版編譯

#version 330 core

//====drawCubemapVS
layout(location=0) in vec2 pos;
layout(location=1) in vec3 cubeTexCoord;
varying vec3 varCubeTexCoord;

void main(){
  varCubeTexCoord=cubeTexCoord;
  gl_Position=vec4(pos,0,1);
}

//====drawCubemapPS
varying vec3 varCubeTexCoord;
uniform samplerCube cubeTexture1;

void main(){
  gl_FragColor=texture(cubeTexture1,varCubeTexCoord);
}
前一篇說過OpenGL載入shader的方式跟D3D不一樣,因為進入點規定叫做main(),把多個shader寫在同一個檔案裡有名稱相衝的問題。
我的做法是用「//====」當作分隔記號,然後叫鈷寶弄(寫個Python程式讀這個shader檔)。
鈷寶:嗯,搜尋「//====」,分隔字串……,然後各個字串分開打包……。
包進遊戲裡後,叫艾莉兒一個一個丟給驅動程式編譯。

頂點坐標跟上面Direct3D相同。
初始化時還要呼叫這個,才能有跟D3D11相同的結果。
glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS);

OpenGL,左手坐標系1

與D3D的相同。

實驗二,這樣建貼圖
//貼圖大小
const int TEX_W=64;
const int TEX_H=64;
const int TEX_PIXEL_NUM=TEX_W*TEX_H;

//要建立的物件
uint32_t texture;
uint32_t framebuffer[6];

//create cube texture
glGenTextures(1,&texture);
glBindTexture(GL_TEXTURE_CUBE_MAP,texture);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAX_LEVEL, 0);

glGenFramebuffers(6,cubeFramebuffer);
//六面各建立一個framebuffer物件
for(int i=0;i<6;i++){
  glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X+i, 0, GL_RGBA8,
    TEX_W, TEX_H, 0,GL_BGRA,GL_UNSIGNED_BYTE, 0);
  glBindFramebuffer(GL_FRAMEBUFFER,cubeFramebuffer[i]);
  glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
    GL_TEXTURE_CUBE_MAP_POSITIVE_X+i, texture, 0);
}

更新cubemap的shader這樣寫
//====updateCubemapVS
layout(location=0) in vec2 pos;
varying vec2 varScreenCoord;

void main(){
  //將-1~1變為0~1
  varScreenCoord=pos*0.5+0.5;
  gl_Position=vec4(pos,0,1);
}

//====updateCubemapPS
varying vec2 varScreenCoord;

void main(){
  gl_FragColor=vec4(varScreenCoord.x,0,varScreenCoord.y,1);
}

OpenGL,左手坐標系2

與D3D上下相反。

鈷寶:主人,那個,右手坐標系呢?
嗯,雖然我的引擎使用左手坐標系,順便把各種情況都試試看好了。
與左手坐標系的差別是,+Z與-Z面要對調,畫cubemap的頂點陣列要把Z坐標正負相反。
const DrawCubeVertex DRAW_CUBEMAP_VERTEX[]={
  {-1,   0.25, 1, 1, 1}, //+X左邊
  {-1,  -0.25, 1,-1, 1},
  {-0.5, 0.25, 1, 1,-1}, //+X與-Z的邊
  {-0.5,-0.25, 1,-1,-1},
  {0, 0.25,  -1, 1,-1}, //-Z與-X的邊
  {0,-0.25,  -1,-1,-1},
  {0.5, 0.25, -1, 1, 1}, //-X與+Z的邊
  {0.5,-0.25, -1,-1, 1},
  {1, 0.25,  1, 1, 1}, //+Z右邊
  {1,-0.25,  1,-1, 1},

  {1,-0.25,  0,0,0}, //dummy
  {-1,0.75,  0,0,0},

  {-1, 0.75,  -1, 1, 1}, //+Y face
  {-1, 0.25,   1, 1, 1},
  {-0.5, 0.75,-1, 1,-1},
  {-0.5, 0.25, 1, 1,-1},

  {-0.5, 0.25, 0,0,0}, //dummy
  {-1, -0.25,  0,0,0},

  {-1, -0.25,  1, -1, 1}, //-Y face
  {-1, -0.75, -1, -1, 1},
  {-0.5,-0.25, 1, -1,-1},
  {-0.5,-0.75,-1, -1,-1},
};
其他部分都不用動,由於本程式沒有用到3D坐標轉換,不用修改矩陣。

結果
Direct3D,右手坐標系


OpenGL,右手坐標系




一切都明白了,全部就是下面的圖。
把展開方式改變一下比較好記,改成看cube內部的面,cubemap的應用大部分是假設從cube內部往外看。


OpenGL的framebuffer坐標上下顛倒,查了一下原因,找到這篇解釋。
https://stackoverflow.com/questions/11685608/convention-of-faces-in-opengl-cubemapping
他說因為Cubemap遵循RenderMan的規則,貼圖左上角是原點,但OpenGL定義貼圖原點=左下角=framebuffer的(-1,-1),必須上下顛倒才對得起來。

我在OpenGL原本就把矩陣設成上下顛倒,剛好抵消Y坐標顛倒的效果,詳細看這篇:【程式】倒立的OpenGL貼圖坐標
如果貼圖來自圖檔,一般圖檔以左上角為原點,但OpenGL定義傳給glTexImage2D()的圖以左下角為原點,也會負負得正,所以坐標跟D3D相同。

至於上面這張圖怎麼用?如果在Direct3D左手坐標系,想用framebuffer畫+X面,首先要把坐標轉換到cube本身的坐標系。
從上圖找Direct3D framebuffer坐標的+X面,可看出Fx軸指向-Z,Fy軸指向+Y,畫出下圖A。


但是3D繪圖要把坐標轉換成上圖B,+Z軸指向正面,所以要乘上「繞Y軸轉+90度」的矩陣。(以Y為軸的話,從Z轉到X是正)
如果引擎有lookAt的函式,可以乘上lookAt(1,0,0),上方是(0,1,0)的矩陣。

右手坐標系的話是這樣,要轉成-Z軸向正面。

除了旋轉-90度以外還要把X軸鏡射,culling方向也要隨之變更。

如果cubemap是來自圖檔,也要跟繪製的人講清楚是左手還右手、六個面怎麼排在一張圖裡,否則會變成鏡中世界。



弄清楚基礎知識後可以正式實裝了,艾莉兒,六個面的軸如下,照這樣旋轉鏡頭畫出六個面。
艾莉兒:OK!
//要把鏡頭面對這些方向
const float LOOKAT_DIR[6][3]={
{1,0,0}, //+X
{-1,0,0}, //-X
{0,1,0}, //+Y
{0,-1,0}, //-Y
{0,0,1}, //+Z
{0,0,-1}, //-Z
};

//上方,即旋轉後的Y軸
const float LOOKAT_TOP_DIR[6][3]={
{0,1,0},
{0,1,0},
{0,0,-1},
{0,0,1},
{0,1,0},
{0,1,0},
};

//第三個軸用外積求出
畫X、Z面的時候Y軸都是正上方,畫+Y、-Y面的時候比較特別。
利用一個特性可簡單求出旋轉矩陣:如果知道旋轉後的坐標軸,把三個軸的向量填入3×3矩陣就是旋轉矩陣,要填入行還是列因矩陣運算方式而異,看向量乘以矩陣是向量放左邊還是右邊。

shader有個地方要改,前一篇在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比較的值

——平面shadow map要跟shadow map比較的值是Z坐標。
——cube shadow map要跟shadow map比較的值是XYZ裡絕對值最大的。
這兩句是對的,但如果有一個polygon橫跨cube的兩個面,兩個頂點的「絕對值最大的」不是同一個。


內插之後polygon內部的像素就會算錯,變成像下圖有多餘的影子。


所以找出最大值的工作要在pixel shader裡做。
//vertex shader只求出頂點在shadow map鏡頭坐標系的位置
OUT.shadowMapPos.xyz=mul(float4(worldPos,1), shadowMap.viewMatrix);
OUT.shadowMapPos.w=0;


//pixel shader
float4 shadowMapPos=IN.shadowMapPos;
float3 absValue=abs(shadowMapPos.xyz);
float maxComponent = max(absValue.x, max(absValue.y, absValue.z));
  //要與shadow map比較的值
shadowMapPos.w = maxComponent*shadowMap.projCoef.z + shadowMap.projCoef.w;
shadowMapPos.w/=maxComponent; //仿照vertex shader算完後除以w的步驟
  //用shadow sampler讀取貼圖

float notInShadow=cubeShadowMap1.SampleCmpLevelZero(shadowMapSampler,
    shadowMapPos.xyz, shadowMapPos.w);

鈷寶,打包shader。
鈷寶:嗯。

…………

艾莉兒:經過三篇進度文的努力,總算完成實裝影子的任務啦!
無論光源在前後左右上下都可以產生影子了。




製作途中有個感想,用shadow map實作影子有shadow acne、要考慮解析度、以及一個shadow map可能不夠涵蓋所有物體的問題,另一種方法:shadow volume或許例外情況比較少,做一次就可用在各種狀況。

不過筆者研究過後,目前不用shadow volume的理由如下。
1.模型裡要有相鄰三角形的資訊,不然就要讀取模型時即時產生,PMD、PMX沒有這些資訊。
2.要有geometry shader才比較容易做,考慮支援的硬體和作業系統,決定目前不使用geometry shader。

關於第2點,研究過一些電腦、手機的硬體和GPU的資料後,我開的顯示晶片需求如下。
Windows
Linux
Windows App
Android
:Direct3D 11 feature level 10_0
:OpenGL 3.3
:Direct3D 11 feature level 9_3
:OpenGL ES 3.0
雖然目前還沒有要做手機遊戲,但很難說以後不會做,留著為將來鋪路。上面四個裡面前兩個支援geometry shader,但後兩個沒有,即使現在某個東西用geometry shader寫,將來還是得再寫個不使用geometry shader的版本,不如現在就用不使用geometry shader的方法做。
引用網址:https://home.gamer.com.tw/TrackBack.php?sn=4267764
All rights reserved. 版權所有,保留一切權利

相關創作

同標籤作品搜尋:Cyber Sprite|遊戲製作|程式|3D|Direct3D|OpenGL

留言共 0 篇留言

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

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

前一篇:【進度】3D背景—實裝影... 後一篇:C.S.Lab看板娘介紹...

追蹤私訊

作品資料夾

kirohaㄚㄚㄚㄚㄚ
喜歡東幻的朋友們能到我小屋逛逛喔~看更多我要大聲說14小時前


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

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