大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 29 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,在本系列文的最後章節將製作一個完整的場景作為完結作品:帆船與海,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
看著 Day 28 加上天空以及海面上的天空倒影,本章的目標『帆船與海』幾乎可以算是完成了:
但是初次乍到的使用者,除了觀賞畫面之外應該很難知道操作視角的方式,當然我們可以用 HTML 把說明文字加在畫面中,但是這樣的話就太沒挑戰性了,本篇將在場景中加入一段文字簡單說明移動視角的方法:
拖曳平移視角
透過滑鼠右鍵、滾輪
或是多指觸控手勢
對視角進行轉動、縮放
不論是英文、中文還是任何語言,其實顯示在畫面上時只是一些符號組合在一起形成一幅圖,在 WebGL 中並沒有『透過某個 API 並輸入一個字串,在畫面上就會繪製出該文字』這樣的事情,但是在 CanvasRenderingContext2D
中有,而且透過 gl.texImage2D()
輸入 texture 資料時可以把 <canvas></canvas>
DOM 元素餵進去,把畫在該 <canvas></canvas>
中的圖傳送到 texture 上
這麼一來,我們可以:
<canvas></canvas>
CanvasRenderingContext2D
繪製文字到 <canvas></canvas>
中<canvas></canvas>
輸入到 texture建立一個 function 叫做 createTextTexture
,實做完成時會回傳 WebGL texture,在 setup()
中呼叫並接收放在 app.textures.text
中:
async function setup() {
// ...
const textures = twgl.createTextures(gl, {
// ...
});
+ textures.text = createTextTexture(gl);
// ...
}
+function createTextTexture(gl) {
+}
照著上面的第一步:建立一個暫時用的 <canvas></canvas>
:
function createTextTexture(gl) {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 1024;
}
長寬設定成 1024,這樣的大小應該可以繪製足夠細緻的文字。接下來使用 canvas.getContext('2d')
取得 CanvasRenderingContext2D
,並繪製文字到 canvas
上:
function createTextTexture(gl) {
// const canvas = ...
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 80px serif';
ctx.fillText('拖曳平移視角', canvas.width / 2, canvas.height / 5);
const secondBaseLine = 3 * canvas.height / 5;
const secondLineHeight = canvas.height / 7;
ctx.font = 'bold 70px serif';
ctx.fillText('透過滑鼠右鍵、滾輪', canvas.width / 2, secondBaseLine - secondLineHeight);
ctx.fillText('或是多指觸控手勢', canvas.width / 2, secondBaseLine);
ctx.fillText('對視角進行轉動、縮放', canvas.width / 2, secondBaseLine + secondLineHeight);
}
這邊用的主要是 CanvasRenderingContext2D
的 API,事實上它也可以用來繪製幾何圖形,不過本文的重點是文字,因此只有使用到相關的功能:
.clearRect()
確保整個畫布都是透明黑色 rgba(0, 0, 0, 0)
.fillStyle
設定文字顏色,而 .textAlign = 'center'
, .textBaseline = 'middle'
使待會繪製時以從下筆的點進行水平垂直置中.font
設定字型、字體大小.fillText(string, x, y)
如同本文一開始說的『透過某個 API 並輸入一個字串,在畫面上就會繪製出該文字』,此處的 x
, y
為下筆的位置閱讀一下程式碼的話,應該不難發現 拖曳平移視角
這行字會是 80px
比接下來的文字(70px
)來的大,而且有不少『下筆』位置的計算,總之繪製完畢之後,canvas
看起來像是這樣:
有後面的方格是筆者為了避免在文章中什麼都看不到而加上,表示該區域是透明的。canvas
準備好了,如同 Day 6 一樣建立、輸入 texture,只是先前輸入圖片的位置改成繪製好的 canvas
:
function createTextTexture(gl) {
// ctx.fillText() ...
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0, // level
gl.RGBA, // internalFormat
gl.RGBA, // format
gl.UNSIGNED_BYTE, // type
canvas, // data
);
gl.generateMipmap(gl.TEXTURE_2D);
return texture;
}
要繪製文字 texture 到場景上,需要一個 3D 物件來當成此 texture 的載體,繪製在其表面,最適合的就是一個平面了,這樣的平面也已經有了,因此直接使用現有的 objects.plane.vao
,與其他物件一樣建立一個 function 進行繪製:
function renderText(app, viewMatrix, programInfo) {
const { gl, textures, objects } = app;
gl.bindVertexArray(objects.plane.vao);
const textLeftShift = gl.canvas.width / gl.canvas.height < 1.4 ? 0 : -0.9;
const worldMatrix = matrix4.multiply(
matrix4.translate(textLeftShift, 0, 0),
matrix4.xRotate(degToRad(45)),
matrix4.translate(0, 12.5, 0),
);
twgl.setUniforms(programInfo, {
u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
u_diffuse: [0, 0, 0],
u_diffuseMap: textures.text,
});
twgl.drawBufferInfo(gl, objects.plane.bufferInfo);
}
worldMatrix
的 matrix4.translate(0, 12.5, 0)
與 matrix4.xRotate(degToRad(45))
是為了讓此文字出現在使用者初始時視角位置的前面
matrix4.translate(textLeftShift, 0, 0)
的 textLeftShift
則是有點 RWD 概念,如果裝置為寬螢幕則讓文字面板往左偏移一點使得船可以在一開始不被文字遮到u_diffuseMap: textures.text
u_diffuse
,這邊將之設定成黑色在 render(app)
中呼叫 renderText(app, viewMatrix, programInfo);
,可以看到黑底白字的說明出現:
但是上下顛倒了,為了修正這個問題,我們在 gl.texImage2D()
輸入文字 texture 之前要設定請 WebGL 把輸入資料的 Y 軸顛倒:
function createTextTexture(gl) {
// ...
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(
gl.TEXTURE_2D,
0, // level
gl.RGBA, // internalFormat
gl.RGBA, // format
gl.UNSIGNED_BYTE, // type
canvas, // data
);
gl.generateMipmap(gl.TEXTURE_2D);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
// ...
}
為了避免影響到別的 texture 的載入,用完要設定回來。加入這兩行之後文字就正常囉:
原本半透明的文字 texture 透過 3D 物件渲染到場景變成不透明的了,因為使用的 fragment shader 輸出的 gl_FragColor
的第四個元素,也就是 alpha/透明度,固定是 1
:
void main() {
// ...
gl_FragColor = vec4(
clamp(
diffuse * diffuseBrightness +
u_specular * specularBrightness +
u_emissive,
ambient, vec3(1, 1, 1)
),
1
);
}
這使得窄板螢幕一開始文字會遮住導致看不到主角,是不是有辦法可以讓這個說明看板變成半透明的呢?有的,首先當然是要有一個願意根據 texture 輸出 alpha 值的 fragment shader,因為這個文字看板物件不會需要有光影效果,寫一個簡單的 fragment shader textFragmentShaderSource
給它用:
precision highp float;
uniform vec4 u_bgColor;
uniform sampler2D u_texture;
varying vec2 v_texcoord;
void main() {
gl_FragColor = u_bgColor + texture2D(u_texture, v_texcoord);
}
可以看到除了 u_texture
用來輸入文字 texture 之外還有 u_bgColor
,可以用來輸入整體的底色。與原本的 vertex shader 連結建立對應的 programInfo 並讓看板物件使用:
async function setup() {
// ...
const programInfo = twgl.createProgramInfo(gl, [vertexShaderSource, fragmentShaderSource]);
+ const textProgramInfo = twgl.createProgramInfo(gl, [vertexShaderSource, textFragmentShaderSource]);
const depthProgramInfo = twgl.createProgramInfo(gl, [vertexShaderSource, depthFragmentShaderSource]);
// ...
return {
gl,
- programInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
+ programInfo, textProgramInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
textures, framebuffers, objects,
// ...
}
}
function render(app) {
const {
gl,
framebuffers, textures,
- programInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
+ programInfo, textProgramInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
state,
} = app;
// ...
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
twgl.resizeCanvasToDisplaySize(gl.canvas, state.resolutionRatio);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(programInfo.program);
renderBoat(app, viewMatrix, programInfo);
- renderText(app, viewMatrix, programInfo);
+ gl.useProgram(textProgramInfo.program);
+ renderText(app, viewMatrix, textProgramInfo);
// ...
}
function renderText(app, viewMatrix, programInfo) {
// ...
twgl.setUniforms(programInfo, {
u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
- u_diffuse: [0, 0, 0],
- u_diffuseMap: textures.text,
+ u_bgColor: [0, 0, 0, 0.1],
+ u_texture: textures.text,
});
// ...
}
修改輸入之 uniform,u_texture
輸入文字 texture,而 u_bgColor
使得透明度為 0.1
。看起來像是這樣:
有變化,但是依然不是半透明的,因為還需要請 WebGL 啟用 gl.BLEND
顏色混合功能:
async function setup() {
// ...
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// ...
}
事實上,gl.BLEND
啟用的行為是讓要畫上去的顏色與畫布上現有的顏色進行運算後相加,而運算的方式由 gl.blendFunc(sfactor, dfactor)
設定,
sfactor
表示要畫上去的顏色要乘以什麼,預設值為 gl.ONE
,設定成 gl.SRC_ALPHA
乘以自身之透明度dfactor
表示畫布上的顏色要乘以什麼,預設值為 gl.ZERO
,這個顯然也要修改,要不然底下的顏色就等於完全被覆蓋掉既然是『與畫布上現有的顏色進行運算』,半透明的物件繪製時會需要畫布上已經有繪製好其他物件,我們來看看如果按照『帆船、看板、海洋、天空』的順序繪製會變成什麼樣子:
畫看板時畫布上只有帆船,看板內帆船以外的區域等於是空白畫布的顏色與 u_bgColor
混合的結果,又因為深度已經寫入了看板的距離,在繪製海洋時就被判定成在後面而沒有畫上去形成這樣的現象。因此半透明物件應該要最後畫上:
function render(app) {
// ...
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
twgl.resizeCanvasToDisplaySize(gl.canvas, state.resolutionRatio);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(programInfo.program);
renderBoat(app, viewMatrix, programInfo);
-
- gl.useProgram(textProgramInfo.program);
- renderText(app, viewMatrix, textProgramInfo);
gl.useProgram(oceanProgramInfo.program);
twgl.setUniforms(oceanProgramInfo, globalUniforms);
renderOcean(app, viewMatrix, reflectionMatrix, oceanProgramInfo);
gl.useProgram(skyboxProgramInfo.program);
renderSkybox(app, projectionMatrix, inversedCameraMatrix);
+
+ gl.useProgram(textProgramInfo.program);
+ renderText(app, viewMatrix, textProgramInfo);
}
半透明效果就完成囉:
不過還有一個小問題,u_bgColor
跟文字 texture 的底色都是黑色,半透明的區域顏色應該要比較深才對,怎麼會比較淺呢?如果去修改 HTML 那邊的 <canvas></canvas>
給上 CSS 的背景色,就能發現是因為畫布的看板區域對於 HTML 來說是半透明的,因此網頁底下的顏色就透上來了:
<canvas id="canvas" style="background: green;"></canvas>
讓 <canvas></canvas>
有一個黑色的底色:
<body>
- <canvas id="canvas"></canvas>
+ <canvas id="canvas" style="background: black;"></canvas>
<!-- ... -->
</body>
透明的文字看板就大功告成囉:
完整的程式碼在此: