iT邦幫忙

2021 iThome 鐵人賽

DAY 8
0
Modern Web

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

互動 & 動畫

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

有用到 WebGL 繪製的網頁通常都是『會動』的,有許多會根據使用者操作反應在畫面上,或者是根據時間產生變化的動畫,本篇將基於先前用 texture 渲染的畫面,加入簡易的 WebGL 互動、動畫功能

調整程式碼架構

在加入互動、動畫之前,我們得先調整一下程式碼架構,先前的實做都是從上到下一次執行完畢,畢竟也就只渲染這麼一次,但是接下來會開始有重畫的動作,所以要分成只有一開始要執行一次的初始化程式、更新狀態以及執行『畫』這個動作的程式

這個只執行一次的初始化程式可以叫它 setup(),從建立 WebGL context、編譯連結 GLSL shaders、取得 GLSL 變數位置、下載圖片並建立 texture,最後到設立 buffer 及 vertex attribute 並輸入資料,這些都是一開始初始化要做的工作,因此把這些工作從原本的 main() 抽出來;同時也把初始化時建立的 Javascript 物件像是 gl, program, xxxAttributeLocation, xxxUniformLocation, texture, xxxBuffer 整理起來作為 setup() 的 return 值

像是 xxxAttributeLocation, xxxUniformLocationxxxBuffer 筆者習慣對這些東西分別給一個 Javascript Object 來分類放好:

async function setup() {
  const canvas = document.getElementById('canvas');
  const gl = canvas.getContext('webgl');

  // createShader(); const program = createProgram()...

  const attributes = {
    position: gl.getAttribLocation(program, 'a_position'),
    texcoord: gl.getAttribLocation(program, 'a_texcoord'),
  };
  const uniforms = {
    resolution: gl.getUniformLocation(program, 'u_resolution'),
    texture: gl.getUniformLocation(program, 'u_texture'),
  };
  
  // const texture = gl.createTexture(); ...
  
  const buffers = {};

  // a_position
  buffers.position = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);

  gl.enableVertexAttribArray(attributes.position);
  // gl.vertexAttribPointer(attributes.position, ...
  // gl.bufferData( ...


  // a_texcoord
  buffers.texcoord = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.texcoord);

  gl.enableVertexAttribArray(attributes.texcoord);
  // gl.vertexAttribPointer(attributes.texcoord, ...
  // gl.bufferData( ...

  return {
    gl,
    program, attributes, uniforms,
    buffers, texture,
  };
}

另外一部份就是每次執行『畫』這個動作要做的事情,雖然『畫』這個動作就是 gl.drawArrays() 這行,但是總是要改變些設定,要不然每次畫出來的東西都是一樣的,而 uniform 資料量小,所以常常作為每次繪製不同結果的參數設定,這些工作抽出來叫做 render(),但是因為會需要 setup() 回傳的 WebGL 物件,筆者把 setup() 回傳的整包東西叫做 app,這邊作為參數接收:

function render(app) {
  const {
    gl,
    program, uniforms,
    texture,
  } = app;

  gl.canvas.width = gl.canvas.clientWidth;
  gl.canvas.height = gl.canvas.clientHeight;
  gl.viewport(0, 0, canvas.width, canvas.height);

  gl.useProgram(program);

  gl.uniform2f(uniforms.resolution, gl.canvas.width, gl.canvas.height);

  // texture uniform
  const textureUnit = 0;
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.activeTexture(gl.TEXTURE0 + textureUnit);
  gl.uniform1i(uniforms.texture, textureUnit);
  
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

可以注意到這邊除了設定 uniform 以及最後的 gl.drawArrays() 之外,還包含了調整 canvas 大小、繪製區域的程式,這樣就可以在重畫的時候解決 Day 4 讀取網頁後調整視窗大小時造成的拉伸問題。最後 main() 就是負責把 setup() 以及 render() 串起來:

async function main() {
  const app = await setup();
  window.app = app;
  window.gl = app.gl;

  render(app);
}

main();

調整完後的完整程式碼:github.com/pastleo/webgl-ironman/commit/21efcd5

互動:選擇 texture 圖片

在上篇文章中,我們嘗試了幾種不同的 texture,但是都要修改程式碼來更換,接下來來改成可以透過一組 radio input 來控制要顯示的 texture 圖片,筆者準備了三張 1024x1024 圖片來切換:

cat-1

cat-2

penguin

原本在 setup() 中只建立一個 texture,筆者透過 Promise.all() 以及 async/await 下載並建立 3 個 textures:

  const textures = await Promise.all([
    'https://i.imgur.com/EDLB71ih.jpg',
    'https://i.imgur.com/KT2nqZNh.jpg',
    'https://i.imgur.com/diRWq5ph.jpg',
  ].map(async url => {
    const image = await loadImage(url);
    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(
      gl.TEXTURE_2D,
      0, // level
      gl.RGB, // internalFormat
      gl.RGB, // format
      gl.UNSIGNED_BYTE, // type
      image, // data
    );

    gl.generateMipmap(gl.TEXTURE_2D);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);

    return texture;
  }));

這樣一來 textures 就是一個包含 3 個 texture 的陣列,分別包含了不同的照片。而 a_texcoord 之前為了重複 pattern 調整了數值,要記得改回來:

  // a_texcoord
  // ...
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([
      0, 0, // A
      1, 0, // B
      1, 1, // C

      0, 0, // D
      1, 1, // E
      0, 1, // F
    ]),
    gl.STATIC_DRAW,
  );

然後加入一個叫做 state 的 Javascript object,放上 texture: 0 表示一開始使用第一個 texture,整個 setup() 回傳的 app 也就改成這樣:

   return {
     gl,
     program, attributes, uniforms,
-    buffers, texture,
+    buffers, textures,
+    state: {
+      texture: 0,
+    },
   };
 }

render() 這邊做出對應的修改,在 gl.bindTexture() 的地方根據 state.texture 選取要顯示的 texture:

 function render(app) {
   const {
     gl,
     program, uniforms,
-    texture,
+    textures,
+    state
   } = app;

 // ...
 // texture uniform
   const textureUnit = 0;
-  gl.bindTexture(gl.TEXTURE_2D, texture);
+  gl.bindTexture(gl.TEXTURE_2D, textures[state.texture]);
   gl.activeTexture(gl.TEXTURE0 + textureUnit);
   gl.uniform1i(uniforms.texture, textureUnit);
   
   gl.drawArrays(gl.TRIANGLES, 0, 6);

目前還沒實做控制,只會顯示出第一張圖:

cat-1

為了控制顯示的圖片,我們可以借助 HTML 眾多互動元件的幫助,例如 radio input(以及一點 CSS 把整個 form#controls 固定於網頁右上角):

     width: 100%;
     height: 100%;
   }
+  #controls {
+    position: fixed;
+    top: 0;
+    right: 0;
+    margin: 1rem;
+  }
 </style>
 <body>
   <canvas id="canvas"></canvas>
+  <form id="controls">
+    <div>
+      <input type="radio" id="cat1" name="texture" value="0" checked>
+      <label for="cat1">Cat 1</label>
+      <input type="radio" id="cat2" name="texture" value="1">
+      <label for="cat2">Cat 2</label>
+      <input type="radio" id="penguin" name="texture" value="2">
+      <label for="penguin">Penguin</label>
+    </div>
+  </form>
   <script type="module" src="02-texture-2d.js"></script>
 </body>

看起來像是這樣:

html-radio-input

使用 HTML,意思就是可以使用 DOM API,在 main() 裡頭進行事件監聽 input 事件:

  const controlsForm = document.getElementById('controls');
  controlsForm.addEventListener('input', () => {
    const formData = new FormData(controlsForm);
    app.state.texture = parseInt(formData.get('texture'));

    render(app);
  });

筆者使用 new FormData(form) 直接蒐集整個 form 的資料,之後要加入其他控制項會更方便,使用 app.state.texture = ... 改變要顯示的圖片之後,呼叫 render(app) 重新進行『畫』這個動作,存檔重整之後就會在選擇不同的 radio input 時重新渲染所選的圖片了:

penguin

動畫:隨著時間移動的圖片

可以接受事件重新渲染之後,下一步來讓圖片隨著時間移動,像是這個小時候 DVD 播放器的待機畫面:碰到邊緣會反彈的 DVD logo

首先為了讓圖片位置可以透過 uniform 控制,先來修改控制頂點位置的 vertex shader:

 uniform vec2 u_resolution;
+uniform vec2 u_offset;
  
 varying vec2 v_texcoord;
  
 void main() {
+  vec2 position = a_position + u_offset;
   gl_Position = vec4(
-    a_position / u_resolution * vec2(2, -2) + vec2(-1, 1),
+    position / u_resolution * vec2(2, -2) + vec2(-1, 1),
     0, 1
   );

加入 uniform vec2 u_offset 表示圖片的平移量後,建立 vec2 position 變數運算輸入的頂點位置 a_position 加上圖片平移量 u_offset,既然加上了一個 uniform,記得先取得其變數在 shader 中的位置:

   const uniforms = {
     resolution: gl.getUniformLocation(program, 'u_resolution'),
     texture: gl.getUniformLocation(program, 'u_texture'),
+    offset: gl.getUniformLocation(program, 'u_offset'),
   };

接著調整輸入的頂點座標讓一開始圖片位置在最左上角

   // a_position
   // ...
   gl.bufferData(
     gl.ARRAY_BUFFER,
     new Float32Array([
-      100, 50, // A
-      250, 50, // B
-      250, 200, // C
+      0, 0, // A
+      150, 0, // B
+      150, 150, // C
  
-      100, 50, // D
-      250, 200, // E
-      100, 200, // F
+      0, 0, // D
+      150, 150, // E
+      0, 150, // F

為了方便接下來更新位置,在 setup() 回傳的初始 state 中加上 offset, direction 表示圖片移動的方向,筆者在這邊先隨機產生一個角度 directionDeg,再運用三角函式算出角度對應的方向向量,同時寫上速度:

+  const directionDeg = Math.random() * 2 * Math.PI;
+
   return {
     gl,
     program, attributes, uniforms,
     buffers, textures,
     state: {
       texture: 0,
+      offset: [0, 0],
+      direction: [Math.cos(directionDeg), Math.sin(directionDeg)],
+      speed: 0.08,
     },
+    time: 0,
   };

可以發現這邊還有多輸出一個 time: 0,與待會『隨著時間』移動相關。在 render() 內,輸入剛才的 u_offset unifrom:

gl.uniform2fv(uniforms.offset, state.offset);

WebGL 繪製的修改算是都準備好了,要做的事情就是請 WebGL 用一定的頻率重新渲染以產生動畫效果,這個最好的頻率就是與螢幕更新頻率同步,讓每次更新都是有意義可以反應在螢幕上,Web API 有個 function 叫做 window.requestAnimationFrame,傳入一個 callback function,在下次螢幕更新時執行,若在此 callback 內再呼叫一次 requestAnimationFrame 在下一次螢幕更新時再執行一次,就形成隨著時間更新、重畫的迴圈,因此加上這個 function startLoop:

function startLoop(app, now = 0) {
  const { state, gl } = app;
  const timeDiff = now - app.time;
  app.time = now;

  state.offset = state.offset.map(
    (v, i) => v + state.direction[i] * timeDiff * state.speed
  );

  if (state.offset[0] + 150 > gl.canvas.width) {
    state.direction[0] *= -1;
    state.offset[0] = gl.canvas.width - 150;
  } else if (state.offset[0] < 0) {
    state.direction[0] *= -1;
    state.offset[0] = 0;
  }

  if (state.offset[1] + 150 > gl.canvas.height) {
    state.direction[1] *= -1;
    state.offset[1] = gl.canvas.height - 150;
  } else if (state.offset[1] < 0) {
    state.direction[1] *= -1;
    state.offset[1] = 0;
  }

  render(app);
  requestAnimationFrame(now => startLoop(app, now));
}

如果想要更了解更多關於 requestAnimationFrame 所謂『下一次螢幕更新』的時間點、其與 Javascript event loop 的關係,筆者先前看到一個解釋很好的 talk: Jake Archibald: In The Loop - JSConf.Asia,甚至在最後還有解釋 macro task 什麼時候、如何執行,推薦前端工程師把這個 talk 完整看一次

講解一下 startLoop():

  • 上方第 6 - 8 行用來更新 offset,也就是圖片的平移量
  • 上方第 10 - 24 用來做碰撞測試,碰到邊緣時把 direction 反向進行反彈
  • 上方第 26 行呼叫 render() 重畫畫面
  • 上方第 27 行呼叫 requestAnimationFrame 並傳入一個匿名函式,可以注意到這個匿名函式接收一個參數叫做 now,表示此匿名函式執行的時間,在匿名函式內執行 startLoop() 進行下次更新、渲染形成迴圈
    • 上方第 3 - 4 行透過接收到的 now 計算這次畫面更新與上次更新之間的時間差,並運用在第 7 行平移量的長度,為什麼要這樣做呢?因為每個裝置的螢幕更新頻率不一定都是 60Hz,現在有許多手機或是螢幕支援 120Hz 甚至更快的螢幕更新速度,又或者裝置的性能不足,只有 40Hz 之類的,使用 requestAnimationFrame 更新的我們如果一律每回合移動一單位,那麼在不同的裝置上動畫的速度會不一樣

最後修改 main() 呼叫 startLoop(app),因為已經會在每次螢幕更新時重新渲染,那麼就不用在接收事件時重新渲染了:

     app.state.texture = parseInt(formData.get('texture'));
-
-    render(app);
   });
  
-  render(app);
+  startLoop(app);
 }
  
 main();

筆者同時也加上了速度控制,看起來像是這樣:live 版本

cat-2

完整程式碼可以在下方找到,本篇使用 offset 平移圖片,但在 2D, 3D 渲染的世界中,尤其是 3D,常常利用線性代數方式控制物件的位置,不僅可以平移,更可以縮放、旋轉,並且可以只透過一組矩陣來完成,待下篇來繼續討論


上一篇
More About Textures
下一篇
2D Transform
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言