大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 15 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。介紹完 WebGL 運作方式與 2D transform 後,本章節講述的是建構、transform 並渲染多個 3D 物件,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
到目前的範例,畫面上都只有一個物件,既然已經介紹完 3D 物件的產生、在空間中的 transform、相機控制以及 perspective 投影到畫布上,接下來來讓所謂『場景』比較有場景的感覺,加入一顆球體以及地板:
之前在準備 P 字母 3D 模型資料準備的時候,分別對 a_position
, a_color
製作了 attributes 資料, vertexAttribArray 以及 buffer,這些都屬於 P 字母這個『物件』的內容,接下來要加入其他物件,因此建立一個 objects
來存這些物件,在 objects
下每個物件自己再有一個 Javascript object 來存放 attributes, vertexAttribArray 以及 buffer 等資訊:
async function setup() {
// ...
const objects = {};
// ...
}
把原本 modelBufferArrays.attribs
, modelBufferArrays.numElements
, buffers
放置到 objects.pModel
內:
async function setup() {
// ...
{ // pModel
const { attribs, numElements } = createModelBufferArrays();
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(
gl.ARRAY_BUFFER,
new Float32Array(attribs.a_position),
gl.STATIC_DRAW,
);
// a_color
buffers.color = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.color);
gl.enableVertexAttribArray(attributes.color);
gl.vertexAttribPointer(attributes.color, /* ... */);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(attribs.a_color),
gl.STATIC_DRAW,
);
objects.pModel = {
attribs, numElements,
buffers,
};
}
// ...
}
在 setup()
/ render()
改傳 objects
:
async function setup() {
// ...
return {
gl,
program, attributes, uniforms,
- buffers, modelBufferArrays,
+ objects,
state: {/* ... */},
time: 0,
};
}
function render(app) {
const {
gl,
program, uniforms,
- modelBufferArrays,
+ objects,
state,
} = app;
}
最後修改讓原本使用 modelBufferArrays
的程式改從 objects
取用,並把 P 物件本身的 transform (worldMatrix
) 放在專屬的程式碼區域:
function render(app) {
// ...
{ // pModel
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),
);
gl.uniformMatrix4fv(
uniforms.matrix,
false,
matrix4.multiply(viewMatrix, worldMatrix),
);
gl.drawArrays(gl.TRIANGLES, 0, objects.pModel.numElements);
}
// ...
}
因為待會要加入的物件都是純色,因此不需要傳送 a_color
進去指定每個 vertex / 三角形的顏色,我們可以讓 fragment shader 接收 uniform u_color
來指定整個物件的顏色:
precision highp float;
uniform vec3 u_color;
varying vec3 v_color;
void main() {
gl_FragColor = vec4(v_color + u_color, 1);
}
這邊直接讓兩種來源相加:v_color + u_color
,因為在 a_color
沒輸入的時候 RGB 三個 channel 都會是 0,因此 v_color
就會是 [0, 0, 0]
,對於 a_color
有值的 P 物件來說,我們要做的就是在繪製 P 物件時把 u_color
設定成 [0, 0, 0]
;同時要記得取得 uniform 的位置:
async function setup() {
// ...
const uniforms = {
matrix: gl.getUniformLocation(program, 'u_matrix'),
+ color: gl.getUniformLocation(program, 'u_color'),
};
// ...
}
最後在 render()
時給 P 物件的 u_color
設定成 [0, 0, 0]
:
{ // pModel
// ...
gl.uniform3f(uniforms.color, 0, 0, 0);
// ... gl.drawArrays(...)
}
我們接下來要產生球體以及地板所需的 a_position
,也就是每個三角形各個頂點的位置,難道我們又要寫一長串程式來產生這些資料了嗎?幸好網路上有大大已經幫我們寫好了 -- TWGL: A Tiny WebGL helper Library
這個套件裡面不僅可以產生球體、平面等物件所需的資料,同時他也是一層對 WebGL 的薄薄包裝,讀者們應該也有感受到 WebGL API 的冗長,像是從 Day 6 開始我們自己包裝的 createShader
/ createProgram
,到 vertex attribute, buffer 等操作都有,使得程式碼可以減少不少,在套件首頁上就有不少使用 WebGL API 以及 TWGL 的比較;不過本篇就先只用到 twgl.primitives
來產生球體、平面物件的資料
引入這個套件有很多方法,筆者使用 unpkg 所提供的 CDN 服務,在 ES module 中,直接引用:
import * as twgl from 'https://unpkg.com/twgl.js@4.19.2/dist/4.x/twgl-full.module.js';
首先是球體,使用 twgl.primitives.createSphereVertices(radius, subdivisionsAxis, subdivisionsHeight)
產生球體資料,第一個參數表示半徑、第二三個參數表示要分成多少個區段產生頂點,分越多這個球體就越精緻,我們先用這樣的設定印出來看看:
console.log(
twgl.primitives.createSphereVertices(10, 32, 32)
)
看起來 position
會是我們需要的資料,同時還有 texcoord
取用 texture 的對應位置、normal
法向量,那麼 indices
是什麼?在 WebGL 中,還有一種繪製模式 gl.drawElements()
使得繪製時透過 indices
扮演類似指標的角色去取得其指向之 vertex attribute 的值,可以避免重複的 vertex 資料,不過這個模式之後再來深入介紹,本篇不使用,如果要取得以三角形 vertex 為單位的 attribute 資料,我們需要 twgl.primitives.deindexVertices() 跟著 indices
指標取得直接資料,並且透過 position
資料長度除以 3 得到 numElements
要繪製的頂點數量,接下來 createBuffer
, enableVertexAttribArray
等與之前類似:
async function setup() {
// ...
{ // ball
const attribs = twgl.primitives.deindexVertices(
twgl.primitives.createSphereVertices(10, 32, 32)
);
const numElements = attribs.position.length / attribs.position.numComponents;
const buffers = {};
// a_position
buffers.position = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
gl.enableVertexAttribArray(attributes.position);
gl.vertexAttribPointer(
attributes.position,
3, // size
gl.FLOAT, // type
false, // normalize
0, // stride
0, // offset
);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(attribs.position),
gl.STATIC_DRAW,
);
objects.ball = {
attribs, numElements,
};
}
// ...
}
準備好資料後,在 render()
加上球體的繪製:
function render(app) {
// ...
{ // ball
const worldMatrix = matrix4.multiply(
matrix4.translate(300, -80, 0),
matrix4.scale(3, 3, 3),
);
gl.uniformMatrix4fv(
uniforms.matrix,
false,
matrix4.multiply(viewMatrix, worldMatrix),
);
gl.uniform3f(uniforms.color, 67/255, 123/255, 208/255);
gl.drawArrays(gl.TRIANGLES, 0, objects.ball.numElements);
}
// ...
}
存檔重整後,球體有出現了,但是原本的 P 物體消失,畫面中還有一條不知道是什麼的東西:
為什麼呢?在 render()
分別繪製 P 物件以及球體時,只有切換了 uniform,而在 setup()
設定好的 vertex attribute 與 buffer 之間的關係顯然在 render()
這邊沒有進行切換,事實上,在 setup()
中第二次呼叫的 gl.vertexAttribPointer()
就把 position attribute 改成與球體的 position buffer 綁定,因此最後繪製時兩次 gl.drawArrays()
都是繪製球體,只是第一次繪製時候 objects.pModel.numElements
比球體頂點數少很多所以只有一小條出現
要解決這個問題,我們需要 Vertex Attribute Object 這個功能,將在下一篇繼續介紹,本篇的完整程式碼: