專案起因
經過對 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 = https://www.cnblogs.com/o2team/p/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);
其實我們可以在rgb3個通道中傳入不同型別的泡泡資料(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),不定時推送文章:

轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/340402.html
標籤:JavaScript
