WebGL著色器渲染小遊戲實戰

凹凸實驗室發表於2021-10-28

專案起因

經過對 GLSL 的瞭解,以及 shadertoy 上各種專案的洗禮,現在開發簡單互動圖形應該不是一個怎麼困難的問題了。下面開始來對一些已有業務邏輯的專案做GLSL渲染器替換開發。

起因是看到某些小遊戲廣告,感覺機制有趣,實現起來應該也不會很複雜,就嘗試自己開發一個。

/img/bVcVGQN

遊戲十分簡單,類似泡泡龍一樣的從螢幕下方中間射出不同顏色大小的泡泡,泡泡上浮到頂部,相同顏色的泡泡可以合併成大一級的不同顏色泡泡。簡單說就是一個上下反過來的合成大西瓜。

較特別的地方是為了表現泡泡的質感,在顏色相同的泡泡靠近時,會有水滴表面先合併的效果,這一部分就需要用到著色器渲染來實現了。

專案結構

先對邏輯分層

最上層為遊戲業務邏輯Game,管理遊戲開始、結束狀態,響應使用者輸入,記錄遊戲分數等。

其次為遊戲邏輯驅動層Engine,管理遊戲元素,暴露可由使用者控制的動作,引用渲染器控制遊戲場景渲染更新。

再往下是物理引擎模組Physics,管理遊戲元素之間的關係,以及實現Engine需要的介面。

與引擎模組並列的是渲染器模組Renderer,讀取從Engine輸入的遊戲元素,渲染遊戲場景。

這樣分層的好處是,各個模組可以獨立替換/修改;例如在GLSL渲染器開發完成前,可以替換成其他的渲染器,如2D canvas渲染器,甚至使用HTML DOM來渲染。

結構圖如下:

/img/bVcVGQO

遊戲邏輯實現

遊戲業務邏輯 Game

因為遊戲業務比較簡單,這一層只負責做這幾件事:

  1. 輸入HTML canvas元素,指定遊戲渲染範圍
  2. 初始化驅動層Engine
  3. 監聽使用者操作事件touchend/click,呼叫Engine控制射出泡泡
  4. 迴圈呼叫Engineupdate更新方法,並檢查超過指定高度的泡泡數量,如數量超過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呼叫。

指定了物理引擎模組需提供以下介面方法:

  1. 在指定的位置生成固定的泡泡,供使用者作下一次操作時使用
  2. 把固定的泡泡按指定的方向射出

在更新方法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,沒別的原因,就是因為之前有專案經驗,並且自帶一個渲染器,可以拿來輔助我們自己渲染的開發。

包括上一節驅動層提到的,物理引擎模組需要實現以下幾個功能:

  1. 在指定的位置生成固定的泡泡,供使用者作下一次操作時使用
  2. 把固定的泡泡按指定的方向射出
  3. 檢查是否有相同顏色的泡泡相撞
  4. 相撞的相同顏色泡泡合併為高一級的泡泡

在這之前我們先需要初始化場景:

0.場景搭建

左、右、下的邊框使用普通的矩形碰撞體實現。

頂部的半圓使用預先畫好的SVG圖形,使用matter.jsSVG類的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. 在每個更新週期計算源泡泡和目標泡泡位置的差值,得到源泡泡需要移動的向量
  2. 移動向量的1/8,在下一個更新週期重複1、2的操作
  3. 當兩個泡泡的位置差值小於一個較小的值(這裡設為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.jsrender:

class Physics {
  constructor(...) {
    ...
    this.render = Matter.Render.create(...)
    Matter.Render.run(this.render)
  }
}

/img/bVcVGQP

開發定製渲染器

接下來該說一下渲染器的實現了。

先說一下這種像是兩滴液體靠近,邊緣合併的效果是怎麼實現的。

/img/bVcVGQQ

如果我們把眼鏡脫下,或焦點放遠一點,大概可以看到這樣的影像:

/img/bVcVGQR

看到這裡可能就有人猜到是怎樣實現的了。

是的,就是利用兩個邊緣徑向漸變亮度的圓形,在它們的漸變邊緣疊加的位置,亮度的相加能達到圓形中心的程度。

然後在這個漸變邊緣的圖形上加一個階躍函式濾鏡(低於某個值置為0,高於則置1),就可以得出第一張圖的效果。

著色器結構

因為泡泡的數量是一直變化的,而片段著色器fragmentShaderfor迴圈判斷條件(如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屬性。

再呼叫WebGLdrawArray(gl.POINTS)方法畫點,使每個泡泡渲染成一個頂點。

頂點預設渲染成一個方塊,所以我們在片段著色器中,取頂點渲染範圍的座標(內建屬性)gl_PointCoord到頂點中心點(vec2(0.5, 0.5))距離畫邊緣亮度徑向漸變的圓。

如下圖,我們應該能得到每個泡泡都渲染成燈泡一樣的效果:

注意這裡的WebGL上下文需要指定混合畫素演算法,否則每個頂點的範圍會覆蓋原有的影像,觀感上為每個泡泡帶有一個方形的邊框
gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
gl.enable(gl.BLEND);

/img/bVcVGQW

如上文所說的,我們還需要給這個影像加一個階躍函式濾鏡;但我們不能在上面的片段著色器上直接採用階躍函式處理輸出,因為它是對每個頂點獨立渲染的,不會帶有其他頂點在當前頂點範圍內的資訊,也就不會有前面說的「亮度相加」的計算可能。

一個思路是將上面著色器的渲染影像作為一個紋理,在另一套著色器上做階躍函式處理,作最後實際輸出。

對於這樣的多級處理,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個引數,分別是:

  1. a_Position: 紋理渲染的面的頂點座標,因為這裡的紋理是鋪滿全畫布,所以是畫布的四個角
  2. a_textcoord: 各個頂點的紋理uv座標,因為紋理大小和渲染大小不一樣(紋理大小為1024*1024,渲染大小為畫布大小),所以是從(0.0, 0.0)(width / 1024, height / 1024)
  3. 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);
  }
}

/img/bVcVGQ0

不同型別的泡泡區別

在上一節中,實現了遊戲裡不同位置、不同大小的泡泡在畫布上的繪製,也實現了泡泡之間粘合的效果,但是所有的泡泡都是一樣的顏色,而且不能合併的泡泡之間也有粘合的效果,這不是我們想要的效果;

在這一節,我們把這些不同型別泡泡做出區別。

要區分各種型別的泡泡,可以在第一套著色器中只傳入某個型別的泡泡資訊,重複繪製出紋理供第二套場景著色器使用。但每次只繪製一個型別的泡泡會增加很多的繪製次數。

其實在上一節的場景著色器中,只使用了紅色通道,而綠色、藍色通道的值和紅色是一樣的:

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()// 增加階躍濾鏡
  }
}

/img/bVcVGQ1

經過以上處理,整個遊戲已基本完成,在這以上可以再修改泡泡的樣式、新增分數展示等的部分。

完整專案原始碼可以訪問: https://github.com/wenxiongid/bubble

歡迎關注凹凸實驗室部落格:aotu.io

或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章。

相關文章