iT邦幫忙

2021 iThome 鐵人賽

DAY 11
0
Modern Web

如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起系列 第 11

Orthogonal 3D 投影

大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 11 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容

"Orthogonal" 查詢字典的話得到的意思是:直角的,正交的,垂直的,而 orthogonal 3D projection 筆者的理解是:從 3D 場景中選取一個長方體區域作為 clip space 投影到畫布上,事實上與先前 2D 投影非常類似,只是多一個維度 z,在 orthogonal 3D 投影時叫做深度,本篇的目標將以 orthogonal 3D 投影的方式渲染一個 P 文字形狀的 3D 物件

03-3d-objects.html / 03-3d-objects.html

這個章節使用新的一組 .html / .js 作為開始,完整程式碼可以在這邊找到:github.com/pastleo/webgl-ironman/commit/bc7c806,絕大部分的程式碼在先前的章節都有相關的說明了,以下幾點比較值得注意的:

  • vertex attribute 有:a_position, a_colora_color 作為 fragment shader 輸出顏色使用,顯示的結果將會是一個一個由 a_color 指定的色塊(或是漸層,如果一個三角形內的顏色不同的話)
  • lib/matrix.js 內實做了三維運算需要的 matrix4 系列 function,同樣因為平移需要再加上一個運算時多餘的維度而成為四維矩陣
    • 在三維場景中能夠以 x 軸、y 軸、z 軸旋轉,因此旋轉部份有 xRotate, yRotate, zRotate
  • a_position, a_color 在起始點的 buffer 傳入空陣列、viewMatrix, worldMatrix 起始點為 matrix4.identity(),什麼都不做、gl.drawArrays(gl.TRIANGLES, 0, 0); 繪製 0 個頂點,這些都將在本篇補上
  • 本篇不會用到動畫效果,因此 startLoop 被註解不會用到

仔細看的話,會發現在 vertex shader 中的 a_position 宣告型別為 vec4,可以直接與 4x4 矩陣 u_matrix 相乘,但是在設定傳入的資料時候 gl.vertexAttribPointer() 指定的長度只有 3,那麼剩下的向量第四個元素(接下來稱為 w)的值怎麼辦?有意思的是,在提供的資料長度不足或是沒有提供時 x, y, z 預設值為 0,而 w 很有意思地預設值是 1 ,對於所有 vec4 的 vertex attribute 都是如此,這樣一來就可以符合平移時多餘維度為 1 的需求,很巧的是,如果今天這個 attribute 是顏色,那意義上就變成 alpha 預設值是 1

P 文字形狀的 3D 物件

以下是筆者在設計時畫的圖:

P-model

  • 最左邊的圖表示此物件正面的樣子,第二張為從上方透視的底面,這兩張表示了各個頂點的編號
    • 同時以 a, b, c, d 表示特定邊長的長度,以便為各個頂點定位座標
  • 右邊兩張圖表示各個長方形的編號,最右邊的圖表示從上方透視的底面

這邊的任務是要為此 3D 物件產生對應的 a_position, a_color 陣列,可以想像要建立的資料不少,直接寫在 setup() 內很快就會讓 setup() 失去焦點,因此建立 function createModelBufferArrays(),選定 a, b, c, d 的數值後第一步驟就是產生各個頂點的座標:

function createModelBufferArrays() {
  // positions
  const a = 40, b = 200, c = 60, d = 45;

  const points = [0, d].flatMap(z => ([
    [0, 0, z], // 0, 13
    [0, b, z],
    [a, b, z],
    [a, 0, z],
    [2*a+c, 0, z], // 4, 17
    [a, a, z],
    [2*a+c, a, z],
    [a, 2*a, z],
    [2*a+c, 2*a, z], // 8, 21
    [a, 3*a, z],
    [2*a+c, 3*a, z],
    [a+c, a, z],
    [a+c, 2*a, z], // 12, 25
  ]));
}

在上圖中頂點的編號對應 points 陣列中的 index 值,因為正面、底面的座標位置只有 z 軸前後的差別,因此用 flatMap 讓程式碼更少一點,筆者在程式碼中某些座標的右方有註解表示其對應的頂點編號

不過 points 不能當成 a_position 陣列,a_position 陣列必須是一個個三角形的頂點,以P 文字形狀的 3D 物件來說,所以的面都可以由長方形組成,兩個三角形可以形成一個長方形,因此寫一個 function 接受四個頂點座標,產生兩個三角形的 a_position 陣列:

function rectVertices(a, b, c, d) {
  return [
    ...a, ...b, ...c,
    ...a, ...c, ...d,
  ];
}

有了這個工具之後,a_position 就可以以長方形為單位寫成:

const a_position = [
  ...rectVertices(points[0], points[1], points[2], points[3]), // 0
  ...rectVertices(points[3], points[5], points[6], points[4]),
  ...rectVertices(points[7], points[9], points[10], points[8]),
  ...rectVertices(points[11], points[12], points[8], points[6]),
  ...rectVertices(points[13], points[16], points[15], points[14]), // 4
  ...rectVertices(points[16], points[17], points[19], points[18]),
  ...rectVertices(points[20], points[21], points[23], points[22]),
  ...rectVertices(points[24], points[19], points[21], points[25]),
  ...rectVertices(points[0], points[13], points[14], points[1]), // 8
  ...rectVertices(points[0], points[4], points[17], points[13]),
  ...rectVertices(points[4], points[10], points[23], points[17]),
  ...rectVertices(points[9], points[22], points[23], points[10]),
  ...rectVertices(points[9], points[2], points[15], points[22]), // 12
  ...rectVertices(points[2], points[1], points[14], points[15]),
  ...rectVertices(points[5], points[7], points[20], points[18]),
  ...rectVertices(points[5], points[18], points[24], points[11]),
  ...rectVertices(points[11], points[24], points[25], points[12]), // 16
  ...rectVertices(points[7], points[12], points[25], points[20]),
];

同樣地,在 a_position 陣列中某些 rectVertices() 呼叫右方有註解表示其對應在上圖中的長方形

完成 a_position 之後,a_color 也要為三角形每個頂點產生對應資料,筆者的設計是一個平面使用一個顏色,也就是一個長方形(兩個三角形,共 6 個頂點)的顏色至少都會一樣,同時希望面的顏色是隨機,因此寫了以下 function:

function rectColor(color) {
  return Array(6).fill(color).flat();
}

function randomColor() {
  return [Math.random(), Math.random(), Math.random()];
}

筆者私心想要正面顏色使用筆者的主題顏色,除此之外隨機產生,因此 a_color 的產生如下:

// a_color
const frontColor = [108/255, 225/255, 153/255];
const backColor = randomColor();
const a_color = [
  ...rectColor(frontColor), // 0
  ...rectColor(frontColor),
  ...rectColor(frontColor),
  ...rectColor(frontColor),
  ...rectColor(backColor), // 4
  ...rectColor(backColor),
  ...rectColor(backColor),
  ...rectColor(backColor),
  ...rectColor(randomColor()), // 8
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()), // 12
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()),
  ...rectColor(randomColor()), // 16
  ...rectColor(randomColor()),
];

最後回傳整個『P 文字形狀的 3D 物件』相關的全部資料,除了 vertex attributes 之外,待會 gl.drawArrays() 需要知道要繪製的頂點數量,在這邊也以 numElements 回傳:

return {
  numElements: a_position.length / 3,
  attribs: {
    a_position, a_color,
  },
}

Orthogonal 3D 繪製

辛苦寫好了 createModelBufferArrays,當然得在 setup() 呼叫,把 attribute 資料傳送到對應的 buffer 內:

// async function setup() {
// ...
const modelBufferArrays = createModelBufferArrays();

// ...

gl.bufferData(
  gl.ARRAY_BUFFER,
  new Float32Array(modelBufferArrays.attribs.a_position),
  gl.STATIC_DRAW,
);

// ...

gl.bufferData(
  gl.ARRAY_BUFFER,
  new Float32Array(modelBufferArrays.attribs.a_color),
  gl.STATIC_DRAW,
);

// ...

return {
  gl,
  program, attributes, uniforms,
  buffers, modelBufferArrays,
  state: {
  },
  time: 0,
};

同時也把 modelBufferArrays 也回傳,在 render() 使用,並讓 viewMatrix 使用 matrix4.projection() 產生,orthogonal projection 就是在這行發生:

function render(app) {
   const {
     gl,
     program, uniforms,
+    modelBufferArrays,
     state,
   } = app;
   
   // ...

-  const viewMatrix = matrix4.identity();
+  const viewMatrix = matrix4.projection(gl.canvas.width, gl.canvas.height, 400);
   const worldMatrix = matrix4.identity();
  
   // ...
  
-  gl.drawArrays(gl.TRIANGLES, 0, 0);
+  gl.drawArrays(gl.TRIANGLES, 0, modelBufferArrays.numElements);
 }

存檔,來看看結果:

p-incorrect-front-color

因為是其他顏色是隨機產生,讀者跟著跑到這邊的話看到的不一定是這個顏色

有東西是有東西,但是正面顏色怎麼不是主題色?像是這張圖的底色才是筆者的主題色:

bottom-color-is-the-theme

事實上,在 WebGL 繪製時,假設先繪製了一個三角形,然後再繪製了一個三角形在同一個位置,那麼後面的三角形會覆蓋掉之前的顏色,也就是說當前看到的顏色是底面的顏色(上方程式碼中的 backColor),解決這個問題的其中一個方法是啟用『只繪製正面面向觀看者的三角形』功能 gl.CULL_FACE,當三角形的頂點順序符合右手開掌的食指方向時,大拇指的方向即為三角形正面,如下圖所示的長方形(或是說兩個三角形)的正面朝觀看者:

cull-face

筆者已經在上方建立 a_position 時使得組成底面的三角形面向下,因此只要加上這行:

gl.enable(gl.CULL_FACE);

底面就會因為面向下,其正面沒有對著觀看者,不會被繪製,可以看到正面了:

p-correct-color

轉一下,看起來比較 3D

因為使用 matrix4.projection() 做 orthogonal 投影,其實就是投影到 xy 平面上,這個 3D 模組投影下去看不出來是 3D 的,因此串上各個 transform,尤其是旋轉,使之看起來真的是 3D,首先在 setup() 回傳初始 tranform 值:

// async function setup() {
// ...
return {
  gl,
  program, attributes, uniforms,
  buffers, modelBufferArrays,
  state: {
    projectionZ: 400,
    translate: [150, 100, 0],
    rotate: [degToRad(30), degToRad(30), degToRad(0)],
    scale: [1, 1, 1],
  },
  time: 0,
};

可以注意到除了 translate, rotate, scale 之外,筆者也加上 projectionZ,之後讓使用者可以控制 z 軸 clip space,接著在 render() 串上 worldMatrix 矩陣的產生:

const viewMatrix = matrix4.projection(gl.canvas.width, gl.canvas.height, state.projectionZ);
const worldMatrix = matrix4.multiply(
  matrix4.translate(...state.translate),
  matrix4.xRotate(state.rotate[0]),
  matrix4.yRotate(state.rotate[1]),
  matrix4.zRotate(state.rotate[2]),
  matrix4.scale(...state.scale),
);

結果看起來像是這樣,是 3D 了,但是顯然怪怪的:

without-depth-test

在上面已經啟用 gl.CULL_FACE,不面向觀看者的面確實不會被繪製,但是不夠,下圖箭頭指著的面是面向觀看者的,因為在 a_position 上排列在正面之後,導致正面繪製之後被面覆蓋過去:

without-depth-test-problem-surfaces

調換 a_position 或許可以解決,不過如果讓使用者可以旋轉,旋轉到背面時那麼又會露出破綻,因此需要另一個功能:gl.DEPTH_TEST,也就是深度測試,在 vertex shader 輸出的 gl_Position.z 除了給 clip space 之外,也可以作為深度資訊,如果準備要畫上的 pixel 比原本畫布上的來的更接近觀看者,顏色才會覆蓋上去,因此加入這行啟用這個功能:

gl.enable(gl.DEPTH_TEST);

耶,一切就正確囉:

correct-3d-p

最後筆者也加入使用者控制 transform 功能,本篇完整程式碼可以在這邊找到:

不過使用 orthogonal 投影方法畫出來的畫面與我們在生活中從眼睛、相機看到的其實不同,待下篇來介紹更接近現實生活眼睛看到的成像方式:Perspective projection


上一篇
2D transform Continued
下一篇
Perspective 3D 成像
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言