專案起因
經過對 GLSL 的瞭解,以及 shadertoy 上各種專案的洗禮,現在開發簡單互動圖形應該不是一個怎麼困難的問題了。下面開始來對一些已有業務邏輯的專案做GLSL渲染器替換開發。
起因是看到某些小遊戲廣告,感覺機制有趣,實現起來應該也不會很複雜,就嘗試自己開發一個。
遊戲十分簡單,類似泡泡龍一樣的從螢幕下方中間射出不同顏色大小的泡泡,泡泡上浮到頂部,相同顏色的泡泡可以合併成大一級的不同顏色泡泡。簡單說就是一個上下反過來的合成大西瓜。
較特別的地方是為了表現泡泡的質感,在顏色相同的泡泡靠近時,會有水滴表面先合併的效果,這一部分就需要用到著色器渲染來實現了。
專案結構
先對邏輯分層
最上層為遊戲業務邏輯Game
,管理遊戲開始、結束狀態,響應使用者輸入,記錄遊戲分數等。
其次為遊戲邏輯驅動層Engine
,管理遊戲元素,暴露可由使用者控制的動作,引用渲染器控制遊戲場景渲染更新。
再往下是物理引擎模組Physics
,管理遊戲元素之間的關係,以及實現Engine
需要的介面。
與引擎模組並列的是渲染器模組Renderer
,讀取從Engine
輸入的遊戲元素,渲染遊戲場景。
這樣分層的好處是,各個模組可以獨立替換/修改;例如在GLSL渲染器開發完成前,可以替換成其他的渲染器,如2D canvas
渲染器,甚至使用HTML DOM來渲染。
結構圖如下:
遊戲邏輯實現
遊戲業務邏輯 Game
因為遊戲業務比較簡單,這一層只負責做這幾件事:
- 輸入HTML canvas元素,指定遊戲渲染範圍
- 初始化驅動層
Engine
- 監聽使用者操作事件
touchend/click
,呼叫Engine
控制射出泡泡 - 迴圈呼叫
Engine
的update
更新方法,並檢查超過指定高度的泡泡數量,如數量超過0則停止遊戲
class Game {
constructor(canvas) {
this.engine = new Engine(canvas)
document.addEventListener('touchend', (e) => {
if(!this.isEnd) {
this.shoot({
x: e.pageX,
y: e.pageY
}, randomLevel())
}
})
}
shoot(pos, newBallLevel) {
// 已準備好的泡泡射出去
this.engine.shoot(pos, START_V)
// 在初始點生成新的泡泡
this.engine.addStillBall(BALL_INFO[newBallLevel])
}
update() {
this.engine.update()
let point = 0;
let overflowCount = 0;
this.engine.physics.getAllBall().forEach(ball => {
if(!ball.isStatic){
point += Math.pow(2, ball.level);
if (ball.position.y > _this.sceneSize.width * 1.2) {
overflowCount++
}
}
})
if(overflowCount > 1){
this.gameEnd(point);
}
}
gameEnd(point) {
this.isEnd = true
...
}
}
驅動層 Engine
這一層的邏輯負責管理物理引擎Physics
和渲染器模組Renderer
,並暴露互動方法供Game
呼叫。
指定了物理引擎模組需提供以下介面方法:
- 在指定的位置生成固定的泡泡,供使用者作下一次操作時使用
- 把固定的泡泡按指定的方向射出
在更新方法update
裡,讀取所有泡泡所在的位置和大小、等級顏色資訊,再呼叫渲染器渲染泡泡。
class Engine {
constructor(canvas) {
this.renderer = new Renderer(canvas)
this.physics = new Physics()
}
addStillBall({ pos, radius, level }) {
this.physics.createBall(pos, radius, level, true)
this.updateRender()
}
shoot(pos, startV) {
this.physics.shoot(pos, startV)
}
updateRender() {
// 更新渲染器渲染資訊
}
update() {
// 呼叫渲染器更新場景渲染
this.renderer.draw()
}
}
物理引擎模組 Physics
物理引擎使用了matter.js
,沒別的原因,就是因為之前有專案經驗,並且自帶一個渲染器,可以拿來輔助我們自己渲染的開發。
包括上一節驅動層提到的,物理引擎模組需要實現以下幾個功能:
- 在指定的位置生成固定的泡泡,供使用者作下一次操作時使用
- 把固定的泡泡按指定的方向射出
- 檢查是否有相同顏色的泡泡相撞
- 相撞的相同顏色泡泡合併為高一級的泡泡
在這之前我們先需要初始化場景:
0.場景搭建
左、右、下的邊框使用普通的矩形碰撞體實現。
頂部的半圓使用預先畫好的SVG
圖形,使用matter.js
裡SVG
類的pathToVertices
方法生成碰撞體,插入到場景中。
因為泡泡都是向上漂浮的,所以置重力方向為y軸的負方向。
// class Physics
constructor() {
this.matterEngine = Matter.Engine.create()
// 置重力方向為y軸負方向(即為上)
this.matterEngine.world.gravity.y = -1
// 新增三面牆
Matter.World.add(this.matterEngine.world, Matter.Bodies.rectangle(...))
...
...
// 新增上方圓頂
const path = document.getElementById('path')
const points = Matter.Svg.pathToVertices(path, 30)
Matter.World.add(this.matterEngine.world, Matter.Bodies.fromVertices(x, y, [points], ...))
Matter.Engine.run(this.matterEngine)
}
1.在指定的位置生成固定的泡泡,供使用者作下一次操作時使用
建立一個圓型碰撞體放到場景的指定位置,並記錄為Physics
的內部屬性供射出方法使用。
// class Physics
createBall(pos, radius, level, isStatic) {
const ball = Matter.Bodies.circle(pos.x, pos.y, radius, {
...// 不同等級不同的大小通過scale區分
})
// 如果生成的是固定的泡泡,則記錄在屬性上供下次射出時使用
if(isStatic) {
this.stillBall = ball
}
Matter.World.add(this.matterEngine.world, [ball])
}
2.把固定的泡泡按指定的方向射出
射出的方向由使用者的點選位置決定,但射出的速度是固定的。
可以通過點選位置和原始位置連線的向量,作歸一化後乘以初速度大小計算。
// class Physics
// pos: 點選位置,用於計算射出方向
// startV: 射出初速度
shoot(pos, startV) {
if(this.stillBall) {
// 計算點選位置與原始位置的向量,歸一化(使長度為1)之後乘以初始速度大小
let v = Matter.Vector.create(pos.x - this.stillBall.position.x, pos.y - this.stillBall.position.y)
v = Matter.Vector.normalise(v)
v = Vector.mult(v, startV)
// 設定泡泡為可活動的,並把初速度賦予泡泡
Body.setStatic(this.stillBall, false);
Body.setVelocity(this.stillBall, v);
}
}
3.檢查是否有相同顏色的泡泡相撞
其實matter.js
是有提供兩個碰撞體碰撞時觸發的collisionStart
事件的,但是對於碰撞後合併生成的泡泡,即使與相同顏色的泡泡觸碰,也不會觸發這個事件,所以只能手動去檢測兩個泡泡是否碰撞。
這裡使用的方法是判斷兩個圓形的中心距離,是否小於等於半徑之和,是則判斷為碰撞。
// class Physics
checkCollision() {
// 拿到活動中的泡泡碰撞體的列表
const bodies = this.getAllBall()
let targetBody, srcBody
// 逐對泡泡碰撞體遍歷
for(let i = 0; i < bodies.length; i++) {
const bodyA = bodies[i]
for(let j = i + 1; j < bodies.length; j++) {
const bodyB = bodies[j]
if(bodyA.level === bodyB.level) {
// 用距離的平方比較,避免計算開平方
if(getDistSq(bodyA.position, bodyB.position) <= 4 * bodyA.circleRadius * bodyA.circleRadius) {
// 使用靠上的泡泡作為目標泡泡
if(bodyA.position.y < bodyB.position.y) {
targetBody = bodyA
srcBody = bodyB
} else {
targetBody = bodyB
srcBody = bodyA
}
return {
srcBody,
targetBody
}
}
}
}
}
return false
}
4.相撞的相同顏色泡泡合併為高一級的泡泡
碰撞的兩個泡泡,取y座標靠上的一個作為合併的目標,靠下的一個作為源泡泡,合併後的泡泡座標設在目標泡泡座標上。
源泡泡碰撞設為關閉,並設為固定位置;
只實現合併的功能的話,只需要把源泡泡的位置設為目標泡泡的座標就可以,但為了實現動畫過渡,源泡泡的位置移動做了如下的處理:
- 在每個更新週期計算源泡泡和目標泡泡位置的差值,得到源泡泡需要移動的向量
- 移動向量的
1/8
,在下一個更新週期重複1、2的操作 - 當兩個泡泡的位置差值小於一個較小的值(這裡設為5)時,視為合併完成,銷燬源泡泡,並更新目標泡泡的等級資訊
// class Physics
mergeBall(srcBody, targetBody, callback) {
const dist = Math.sqrt(getDistSq(srcBody.position, targetBody.position))
// 源泡泡位置設為固定的,且不參與碰撞
Matter.Body.setStatic(srcBody, true)
srcBody.collisionFilter.mask = mergeCategory
// 如果兩個泡泡合併到距離小於5的時候, 目標泡泡升級為上一級的泡泡
if(dist < 5) {
// 合併後的泡泡的等級
const newLevel = Math.min(targetBody.level + 1, 8)
const scale = BallRadiusMap[newLevel] / BallRaiusMap[targetBody.level]
// 更新目標泡泡資訊
Matter.Body.scale(targetBody, scale, scale)
Matter.Body.set(targetBody, {level: newLevel})
Matter.World.remove(this.matterEngine.world, srcBody)
callback()
return
}
// 需要繼續播放泡泡靠近動畫
const velovity = {
x: targetBody.position.x - srcBody.position.x,
y: targetBody.position.y - srcBody.position.y
};
// 泡泡移動速度先慢後快
velovity.x /= dist / 8;
velovity.y /= dist / 8;
Matter.Body.translate(srcBody, Matter.Vector.create(velovity.x, velovity.y));
}
因為使用了自定義的方法檢測泡泡碰撞,我們需要在物理引擎的beforeUpdate事件上繫結檢測碰撞和合並泡泡方法的呼叫
// class Physics
constructor() {
...
Matter.Events.on(this.matterEngine, 'beforeUpdate', e => {
// 檢查是否有正在合併的泡泡,沒有則檢測是否有相同顏色的泡泡碰撞
if(!this.collisionInfo) {
this.collisionInfo = this.checkCollision()
}
if(this.collisionInfo) {
// 若有正在合併的泡泡,(繼續)呼叫合併方法,在合併完成後清空屬性
this.mergeBall(this.collisionInfo.srcBody, this.collisionInfo.targetBody, () => {
this.collistionInfo = null
})
}
})
...
}
渲染器模組
GLSL渲染器的實現比較複雜,當前可以先使用matter.js
自帶的渲染器除錯一下。
在Physics
模組中,再初始化一個matter.js
的render
:
class Physics {
constructor(...) {
...
this.render = Matter.Render.create(...)
Matter.Render.run(this.render)
}
}
開發定製渲染器
接下來該說一下渲染器的實現了。
先說一下這種像是兩滴液體靠近,邊緣合併的效果是怎麼實現的。
如果我們把眼鏡脫下,或焦點放遠一點,大概可以看到這樣的影像:
看到這裡可能就有人猜到是怎樣實現的了。
是的,就是利用兩個邊緣徑向漸變亮度的圓形,在它們的漸變邊緣疊加的位置,亮度的相加能達到圓形中心的程度。
然後在這個漸變邊緣的圖形上加一個階躍函式濾鏡(低於某個值置為0,高於則置1),就可以得出第一張圖的效果。
著色器結構
因為泡泡的數量是一直變化的,而片段著色器fragmentShader
的for
迴圈判斷條件(如i < length
)必須是和常量作判斷,(即length
必須是常量)。
所以這裡把泡泡座標作為頂點座標傳入頂點著色器vertexShader
,初步渲染泡泡輪廓:
// 頂點著色器 vertexShader
attribute vec2 a_Position;
attribute float a_PointSize;
void main() {
gl_Position = vec4(a_Position, 0.0, 1.0);
gl_PointSize = a_PointSize;
}
// 片段著色器 fragmentShader
#ifdef GL_ES
precision mediump float;
#endif
void main() {
float d = length(gl_PointCoord - vec2(0.5, 0.5));
float c = smoothstep(0.40, 0.20, d);
gl_FragColor = vec4(vec3(c), 1.0);
}
// 渲染器 Renderer.js
class GLRenderer {
...
// 更新遊戲元素資料
updateData(posData, sizeData) {
...
this.posData = new Float32Array(posData)
this.sizeData = new Float32Array(sizeData)
...
}
// 更新渲染
draw() {
...
// 每個頂點取2個數
this.setAttribute(this.program, 'a_Position', this.posData, 2, 'FLOAT')
// 每個頂點取1個數
this.setAttribute(this.program, 'a_PointSize', this.sizeData, 1, 'FLOAT')
...
}
}
渲染器的js
程式碼中,把每個點的x
,y
座標合併成一個一維陣列,傳到著色器的a_Position
屬性;把每個點的直徑同樣組成一個陣列,傳到著色器的a_PointSize
屬性。
再呼叫WebGL
的drawArray(gl.POINTS)
方法畫點,使每個泡泡渲染成一個頂點。
頂點預設渲染成一個方塊,所以我們在片段著色器中,取頂點渲染範圍的座標(內建屬性)gl_PointCoord
到頂點中心點(vec2(0.5, 0.5)
)距離畫邊緣亮度徑向漸變的圓。
如下圖,我們應該能得到每個泡泡都渲染成燈泡一樣的效果:
注意這裡的WebGL上下文需要指定混合畫素演算法,否則每個頂點的範圍會覆蓋原有的影像,觀感上為每個泡泡帶有一個方形的邊框
gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
gl.enable(gl.BLEND);
如上文所說的,我們還需要給這個影像加一個階躍函式濾鏡;但我們不能在上面的片段著色器上直接採用階躍函式處理輸出,因為它是對每個頂點獨立渲染的,不會帶有其他頂點在當前頂點範圍內的資訊,也就不會有前面說的「亮度相加」的計算可能。
一個思路是將上面著色器的渲染影像作為一個紋理,在另一套著色器上做階躍函式處理,作最後實際輸出。
對於這樣的多級處理,WebGL
建議使用FrameBuffer
容器,把渲染結果繪製在上面;整個完整的渲染流程如下:
泡泡繪製 --> frameBuffer --> texture --> 階躍函式濾鏡 --> canvas
使用frameBuffer
的方法如下:
// 建立frameBuffer
var frameBuffer = gl.createFramebuffer()
// 建立紋理texture
var texture = gl.createTexture()
// 繫結紋理到二維紋理
gl.bindTexture(gl.TEXTURE_2D, texture)
// 設定紋理資訊,注意寬度和高度需是2的次方冪,紋理畫素來源為空
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1024,
1024,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null
)
// 設定紋理縮小濾波器
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
// frameBuffer與紋理繫結
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
使用以下方法,指定frameBuffer
為渲染目標:
gl.bindFramebuffer(gl.FRAMEBUFFER, frameBuffer)
當frameBuffer
繪製完成,將自動儲存到0
號紋理中,供第二次的著色器渲染使用
// 場景頂點著色器 SceneVertexShader
attribute vec2 a_Position;
attribute vec2 a_texcoord;
varying vec2 v_texcoord;
void main() {
gl_Position = vec4(a_Position, 0.0, 1.0);
v_texcoord = a_texcoord;
}
// 場景片段著色器 SceneFragmentShader
#ifdef GL_ES
precision mediump float;
#endif
varying vec2 v_texcoord;
uniform sampler2D u_sceneMap;
void main() {
vec4 mapColor = texture2D(u_sceneMap, v_texcoord);
d = smoothstep(0.6, 0.7, mapColor.r);
gl_FragColor = vec4(vec3(d), 1.0);
}
場景著色器輸入3個引數,分別是:
a_Position
: 紋理渲染的面的頂點座標,因為這裡的紋理是鋪滿全畫布,所以是畫布的四個角a_textcoord
: 各個頂點的紋理uv座標,因為紋理大小和渲染大小不一樣(紋理大小為1024*1024
,渲染大小為畫布大小),所以是從(0.0, 0.0)
到(width / 1024, height / 1024)
u_sceneMap
: 紋理序號,用的第一個紋理,傳入0
// 渲染器 Renderer.js
class Renderer {
...
drawScene() {
// 把渲染目標設回畫布
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// 使用渲染場景的程式
gl.useProgram(sceneProgram);
// 設定4個頂點座標
this.setAttribute(this.sceneProgram, "a_Position", new Float32Array([
-1.0,
-1.0,
1.0,
-1.0,
-1.0,
1.0,
-1.0,
1.0,
1.0,
-1.0,
1.0,
1.0
]), 2, "FLOAT");
// 設定頂點座標的紋理uv座標
setAttribute(sceneProgram, "a_texcoord", new Float32Array([
0.0,
0.0,
canvas.width / MAPSIZE,
0.0,
0.0,
canvas.height / MAPSIZE,
0.0,
canvas.height / MAPSIZE,
canvas.width / MAPSIZE,
0.0,
canvas.width / MAPSIZE,
canvas.height / MAPSIZE
]), 2, "FLOAT");
// 設定使用0號紋理
this.setUniform1i(this.sceneProgram, 'u_sceneMap', 0);
// 用畫三角形面的方法繪製
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
}
}
不同型別的泡泡區別
在上一節中,實現了遊戲裡不同位置、不同大小的泡泡在畫布上的繪製,也實現了泡泡之間粘合的效果,但是所有的泡泡都是一樣的顏色,而且不能合併的泡泡之間也有粘合的效果,這不是我們想要的效果;
在這一節,我們把這些不同型別泡泡做出區別。
要區分各種型別的泡泡,可以在第一套著色器中只傳入某個型別的泡泡資訊,重複繪製出紋理供第二套場景著色器使用。但每次只繪製一個型別的泡泡會增加很多的繪製次數。
其實在上一節的場景著色器中,只使用了紅色通道,而綠色、藍色通道的值和紅色是一樣的:
d = smoothstep(0.6, 0.7, mapColor.r);
其實我們可以在rgb
3個通道中傳入不同型別的泡泡資料(alpha通道的值若為0時,rgb通道的值與設定的不一樣,所以不能使用),這樣在一個繪製過程中可以繪製3個型別的泡泡;泡泡的型別共有8種,需要分3組渲染。我們在第一套著色器繪製泡泡的時候,增加傳入繪製組別和泡泡等級的資料。
並在頂點著色器和片段著色器間增加一個varying
型別資料,指定該泡泡使用哪一個rgb
通道。
// 修改後的頂點著色器 vertexShader
uniform int group;// 繪製的組序號
attribute vec2 a_Position;
attribute float a_Level;// 泡泡的等級
attribute float a_PointSize;
varying vec4 v_Color;// 片段著色器該使用哪個rgb通道
void main() {
gl_Position = vec4(a_Position, 0.0, 1.0);
gl_PointSize = a_PointSize;
if(group == 0){
if(a_Level == 1.0){
v_Color = vec4(1.0, 0.0, 0.0, 1.0);// 使用r通道
}
if(a_Level == 2.0){
v_Color = vec4(0.0, 1.0, 0.0, 1.0);// 使用g通道
}
if(a_Level == 3.0){
v_Color = vec4(0.0, 0.0, 1.0, 1.0);// 使用b通道
}
}
if(group == 1){
if(a_Level == 4.0){
v_Color = vec4(1.0, 0.0, 0.0, 1.0);
}
if(a_Level == 5.0){
v_Color = vec4(0.0, 1.0, 0.0, 1.0);
}
if(a_Level == 6.0){
v_Color = vec4(0.0, 0.0, 1.0, 1.0);
}
}
if(group == 2){
if(a_Level == 7.0){
v_Color = vec4(1.0, 0.0, 0.0, 1.0);
}
if(a_Level == 8.0){
v_Color = vec4(0.0, 1.0, 0.0, 1.0);
}
if(a_Level == 9.0){
v_Color = vec4(0.0, 0.0, 1.0, 1.0);
}
}
}
// 修改後的片段著色器 fragmentShader
#ifdef GL_ES
precision mediump float;
#endif
varying vec4 v_Color;
void main(){
float d = length(gl_PointCoord - vec2(0.5, 0.5));
float c = smoothstep(0.40, 0.20, d);
gl_FragColor = v_Color * c;
}
場景片段著色器分別對3個通道作階躍函式處理(頂點著色器不變),同樣傳入繪製組序號,區別不同型別的泡泡顏色:
// 修改後的場景片段著色器
#ifdef GL_ES
precision mediump float;
#endif
varying vec2 v_texcoord;
uniform sampler2D u_sceneMap;
uniform vec2 u_resolution;
uniform int group;
void main(){
vec4 mapColor = texture2D(u_sceneMap, v_texcoord);
float d = 0.0;
vec4 color = vec4(0.0);
if(group == 0){
if(mapColor.r > 0.0){
d = smoothstep(0.6, 0.7, mapColor.r);
color += vec4(0.86, 0.20, 0.18, 1.0) * d;
}
if(mapColor.g > 0.0){
d = smoothstep(0.6, 0.7, mapColor.g);
color += vec4(0.80, 0.29, 0.09, 1.0) * d;
}
if(mapColor.b > 0.0){
d = smoothstep(0.6, 0.7, mapColor.b);
color += vec4(0.71, 0.54, 0.00, 1.0) * d;
}
}
if(group == 1){
if(mapColor.r > 0.0){
d = smoothstep(0.6, 0.7, mapColor.r);
color += vec4(0.52, 0.60, 0.00, 1.0) * d;
}
if(mapColor.g > 0.0){
d = smoothstep(0.6, 0.7, mapColor.g);
color += vec4(0.16, 0.63, 0.60, 1.0) * d;
}
if(mapColor.b > 0.0){
d = smoothstep(0.6, 0.7, mapColor.b);
color += vec4(0.15, 0.55, 0.82, 1.0) * d;
}
}
if(group == 2){
if(mapColor.r > 0.0){
d = smoothstep(0.6, 0.7, mapColor.r);
color += vec4(0.42, 0.44, 0.77, 1.0) * d;
}
if(mapColor.g > 0.0){
d = smoothstep(0.6, 0.7, mapColor.g);
color += vec4(0.83, 0.21, 0.51, 1.0) * d;
}
if(mapColor.b > 0.0){
d = smoothstep(0.6, 0.7, mapColor.b);
color += vec4(1.0, 1.0, 1.0, 1.0) * d;
}
}
gl_FragColor = color;
}
這裡使用了分多次繪製成3個紋理影像,處理後合併成最後的渲染影像,場景著色器繪製了3次,這需要在每次繪製保留上次的繪製結果;而預設的WebGL
繪製流程,會在每次繪製時清空影像,這需要修改這個預設流程:
// 設定WebGL每次繪製時不清空影像
var gl = canvas.getContext('webgl', {
preserveDrawingBuffer: true
});
class Renderer {
...
update() {
gl.clear(gl.COLOR_BUFFER_BIT)// 每次繪製時手動清空影像
this.drawPoint()// 繪製泡泡位置、大小
this.drawScene()// 增加階躍濾鏡
}
}
經過以上處理,整個遊戲已基本完成,在這以上可以再修改泡泡的樣式、新增分數展示等的部分。
完整專案原始碼可以訪問: https://github.com/wenxiongid/bubble
歡迎關注凹凸實驗室部落格:aotu.io
或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章。