iT邦幫忙

2021 iThome 鐵人賽

DAY 17
0

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

在這個章節中將會加入『光』的元素,使得物體在有光照射的時候才會有顏色,並利用上個章節提到的 twgl 讓程式碼可以寫的比較愉快,最後加入反射光的計算,使得渲染的成像更真實一些

04-lighting.html / 04-lighting.js

這個章節使用新的一組 .html / .js 作為開始,完整程式碼可以在這邊找到:github.com/pastleo/webgl-ironman/commit/2d72d48,這次的起始點跑起來就有一個木質地板跟一顆球:

start-point-screenshot

雖然是 CC0,不過筆者還是標注一下好了,這個場景中使用到的 texture 是在 opengameart.org 找到的:Commission - Medieval, 2048² wooden texture

需要複習『在 WebGL 裡頭使用圖片 (texture) 進行繪製』的話,請參考 Day 6 的內容,在這個起始點也拿 Day 14 實做的相機控制過來,完整的 live 版本在此:

https://static.pastleo.me/webgl-ironman/commits/2d72d48e66e41a968c65b4870c1cfb8391157710/04-lighting.html

目前木質地板與球只是按照原本 texture 上的顏色進行繪製,本篇的目標是加入一個從無限遠的地方照射過來的白色平行光 (directional light),並且在物體表面計算『散射 (diffuse)』之後從任意角度觀察到的顏色,在維基百科上的這張圖表示了散射光的方向:

wiki-diffuse-light

法向量 Normal

因為是白光,所以散射之後的顏色其實就是原本的顏色經過一個明暗度的處理,而明暗度要怎麼計算呢?筆者畫了下方的意示圖嘗試解釋,首先,如果是在光照不到的區域,像是紅色面,與光平行或是背對著光,那麼就會是全黑;被照射到的區域,如綠色與藍色面,因為一個單位的光通量在與垂直的面上可以形成較小的區域(在綠色面上的橘色線段較藍色面短),一個單位的面積獲得的光通量就比較高,因此綠色面比藍色面來的更亮

directional-diffuse

總和以上,入射角越垂直面接近法向量,明暗度越高,不過在 fragment shader 內,把向量算回角度再做比較會太傷本,我們可以取光方向反向的單位向量,再與法向量(也必須是單位向量)做內積,這樣一來會得到 -1 ~ +1 之間的值表示明暗度;幸好 twgl.primitives 產生的資料不只有 position, texcoord,還有 normal,也就是法向量,場景中的球以及地板都是使用 TWGL 生成的,這邊就先來把 normal 傳入 vertex shader 內:

 const vertexShaderSource = `
 attribute vec4 a_position;
 attribute vec2 a_texcoord;
+attribute vec4 a_normal;
 // ...
 void main() {
   // ...
 }
 `;

 const attributes = {
   // ...
+  normal: gl.getAttribLocation(program, 'a_normal'),
 };
async function setup() {
  // ...
  { // both ball and ground
    // ...
    // a_normal
    buffers.normal = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);

    gl.enableVertexAttribArray(attributes.normal);
    gl.vertexAttribPointer(
      attributes.normal,
      attribs.normal.numComponents, // size
      gl.FLOAT, // type
      false, // normalize
      0, // stride
      0, // offset
    );

    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array(attribs.normal),
      gl.STATIC_DRAW,
    );
    // ...
  }
  // ...
}

法向量的旋轉

計算明暗度會在面投影到螢幕的每個 pixel 上進行,也就是 fragment shader,vertex shader 主要的工作是把 a_normal pass 到 fragment shader,但是有一個問題:物體會旋轉,我們讓頂點的位置透過 u_matrix 做 transform,假設有一個物體轉了 90 度,那麼法向量也應該一起轉 90 度才對

但是我們不能直接讓 a_normalu_matrix 相乘得到旋轉後的結果,不僅因為 u_matrix 可能包含了平移、縮放資訊,還有投影到螢幕上的 transform,因此要多傳送一個矩陣,這個矩陣只包含了 worldMatrix (物件本身的 transform 矩陣)的旋轉。至於從 worldMatrix 中抽取只包含旋轉的矩陣,在下面這兩個網頁中有一些數學方法導出接下來的公式:

筆者嘗試理解並統整成這份筆記,結論是:把 worldMatrix 取反矩陣,再取轉置矩陣,就可以得到 transform 法向量用的矩陣 -- 也就是只包含旋轉的 worldMatrix

lib/matrix.js 中已經有 matrix4.inverse(),要補的是 matrix4.transpose(),根據其定義,實做並不難:

export const matrix4 = {
  // ...
  transpose: m => {
    return [
      m[0], m[4], m[8], m[12],
      m[1], m[5], m[9], m[13],
      m[2], m[6], m[10], m[14],
      m[3], m[7], m[11], m[15],
    ];
  },
  // ...
}

假設待會實做在 vertex shader 內轉換 normal 的矩陣叫做 u_normalMatrix,在 setup() 中先取得 uniform 位置:

async function setup() {
  // ...
  const uniforms = {
    // ...
    normalMatrix: gl.getUniformLocation(program, 'u_normalMatrix'),
    // ...
  };
  // ...
}

render() 這邊計算物件的 worldMatrix 後,依照上面講的公式實做計算 u_normalMatrix:

function render(app) {
  // ...
  { // both ball and ground
    // const worldMatrix = matrix4.multiply( ... )
    gl.uniformMatrix4fv(
      uniforms.normalMatrix,
      false,
      matrix4.transpose(matrix4.inverse(worldMatrix)),
    );
  }
  // ...
}

在 vertex shader 內就可以直接相乘,並透過 varying v_normal 傳送到 fragment shader:

 attribute vec4 a_position;
 attribute vec2 a_texcoord;
+attribute vec4 a_normal;
  
 uniform mat4 u_matrix;
+uniform mat4 u_normalMatrix;
  
 varying vec2 v_texcoord;
+varying vec3 v_normal;
  
 void main() {
   gl_Position = u_matrix * a_position;
   v_texcoord = vec2(a_texcoord.x, 1.0 - a_texcoord.y);
+  v_normal = (u_normalMatrix * a_normal).xyz;
 }

計算散射亮度 Diffuse

為了方便調整光線方向觀察不同方向的成像,筆者加入 uniform u_lightDir,並且一開始讓他直直向下(-y 方向) 照射:

async function setup() {
  // ...
  const uniforms = {
    // ...
    lightDir: gl.getUniformLocation(program, 'u_lightDir'),
    // ...
  };
  // ...
  
  return {
    // ...
    state: {
      // ...
      lightDir: [0, -1, 0],
    },
  };
}

因為整個場景的光線方向都是固定的,因此在 render() 物件以外的範圍設定 u_lightDir:

function render(app) {
  // ...
  gl.uniform3f(uniforms.lightDir, ...state.lightDir);
  // ...
}

最後終於可以來寫 fragment shader 實做計算明暗度計算:

precision highp float;

uniform vec3 u_color;
uniform sampler2D u_texture;
uniform vec3 u_lightDir;

varying vec2 v_texcoord;
varying vec3 v_normal;

void main() {
  vec3 color = u_color + texture2D(u_texture, v_texcoord).rgb;
  vec3 normal = normalize(v_normal);
  vec3 surfaceToLightDir = normalize(-u_lightDir);
  float colorLight = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);
  gl_FragColor = vec4(color * colorLight, 1);
}

main() 的第 2 行使用 glsl 內建的 normalize function 計算 v_normal 的單位向量,因為從 vertex shader 過來的 varying 經過『補間』處理可能導致不是單位向量,第 13 行計算『表面到光源』的方向,同樣使之為單位向量

main() 的第 4 行大概就是本篇最關鍵的一行,如同上方講的使用 glsl 的內積 function 計算明暗度:dot(surfaceToLightDir, normal) ,不過為了避免數值跑到負的,再套上 glsl 的 clamp 把範圍限制在 0~1 之間,最後乘上原本的顏色 color,把明暗度套用上去,存檔重整後:

directional-light-applied

筆者稍微把視角往右上角調整了一下,可以看到球體的因為向著正下方的光線,只有上方比較亮,而地板因為原本就是向上的,所以就沒有變化

到了這邊其實應該把 color 改名成 diffuse,因為一個物體其實可以分成不同種類的光對其表面產生的顏色,今天實做的是散射光,之後還會有反射光、自發光等;同時筆者也加上光線方向的使用者控制,完整程式碼可以在這邊找到:

光源方向調整起來像是這樣:

adjusting-light-dir


上一篇
Multiple objects (下)
下一篇
Indexed Element、請 TWGL 替程式碼減肥
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言