主頁 > 企業開發 > WebGL著色器渲染小游戲實戰

WebGL著色器渲染小游戲實戰

2021-10-29 06:46:59 企業開發

專案起因

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

起因是看到某些小游戲廣告,感徑訓制有趣,實作起來應該也不會很復雜,就嘗試自己開發一個,

https://img.uj5u.com/2021/10/29/278628290646361.jpg

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

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

專案結構

先對邏輯分層

最上層為游戲業務邏輯Game,管理游戲開始、結束狀態,回應用戶輸入,記錄游戲分數等,

其次為游戲邏輯驅動層Engine,管理游戲元素,暴露可由用戶控制的動作,參考渲染器控制游戲場景渲染更新,

再往下是物理引擎模塊Physics,管理游戲元素之間的關系,以及實作Engine需要的介面,

與引擎模塊并列的是渲染器模塊Renderer,讀取從Engine輸入的游戲元素,渲染游戲場景,

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

結構圖如下:

https://img.uj5u.com/2021/10/29/278628290646362.png

游戲邏輯實作

游戲業務邏輯 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)
  }
}

https://img.uj5u.com/2021/10/29/278628290646363.jpg

開發定制渲染器

接下來該說一下渲染器的實作了,

先說一下這種像是兩滴液體靠近,邊緣合并的效果是怎么實作的,

https://img.uj5u.com/2021/10/29/278628290646364.gif

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

https://img.uj5u.com/2021/10/29/278628290646365.gif

看到這里可能就有人猜到是怎樣實作的了,

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

然后在這個漸變邊緣的圖形上加一個階躍函式濾鏡(低于某個值置為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 = 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屬性,

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

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

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

注意這里的WebGL背景關系需要指定混合像素演算法,否則每個頂點的范圍會覆寫原有的影像,觀感上為每個泡泡帶有一個方形的邊框

gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
gl.enable(gl.BLEND);

https://img.uj5u.com/2021/10/29/278628290646366.jpg

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

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

對于這樣的多級處理,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);
  }
}

https://img.uj5u.com/2021/10/29/278628290646367.jpg

不同型別的泡泡區別

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

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

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

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

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://img.uj5u.com/2021/10/29/278628290646368.jpg

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

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

歡迎關注凹凸實驗室博客:aotu.io

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

歡迎關注凹凸實驗室公眾號

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

標籤:JavaScript

上一篇:CSS 奇技淫巧 | 巧妙實作文字二次加粗再加邊框

下一篇:CSS 奇技淫巧 | 巧妙實作文字二次加粗再加邊框

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • IEEE1588PTP在數字化變電站時鐘同步方面的應用

    IEEE1588ptp在數字化變電站時鐘同步方面的應用 京準電子科技官微——ahjzsz 一、電力系統時間同步基本概況 隨著對IEC 61850標準研究的不斷深入,國內外學者提出基于IEC61850通信標準體系建設數字化變電站的發展思路。數字化變電站與常規變電站的顯著區別在于程序層傳統的電流/電壓互 ......

    uj5u.com 2020-09-10 03:51:52 more
  • HTTP request smuggling CL.TE

    CL.TE 簡介 前端通過Content-Length處理請求,通過反向代理或者負載均衡將請求轉發到后端,后端Transfer-Encoding優先級較高,以TE處理請求造成安全問題。 檢測 發送如下資料包 POST / HTTP/1.1 Host: ac391f7e1e9af821806e890 ......

    uj5u.com 2020-09-10 03:52:11 more
  • 網路滲透資料大全單——漏洞庫篇

    網路滲透資料大全單——漏洞庫篇漏洞庫 NVD ——美國國家漏洞庫 →http://nvd.nist.gov/。 CERT ——美國國家應急回應中心 →https://www.us-cert.gov/ OSVDB ——開源漏洞庫 →http://osvdb.org Bugtraq ——賽門鐵克 →ht ......

    uj5u.com 2020-09-10 03:52:15 more
  • 京準講述NTP時鐘服務器應用及原理

    京準講述NTP時鐘服務器應用及原理京準講述NTP時鐘服務器應用及原理 安徽京準電子科技官微——ahjzsz 北斗授時原理 授時是指接識訓通過某種方式獲得本地時間與北斗標準時間的鐘差,然后調整本地時鐘使時差控制在一定的精度范圍內。 衛星導航系統通常由三部分組成:導航授時衛星、地面檢測校正維護系統和用戶 ......

    uj5u.com 2020-09-10 03:52:25 more
  • 利用北斗衛星系統設計NTP網路時間服務器

    利用北斗衛星系統設計NTP網路時間服務器 利用北斗衛星系統設計NTP網路時間服務器 安徽京準電子科技官微——ahjzsz 概述 NTP網路時間服務器是一款支持NTP和SNTP網路時間同步協議,高精度、大容量、高品質的高科技時鐘產品。 NTP網路時間服務器設備采用冗余架構設計,高精度時鐘直接來源于北斗 ......

    uj5u.com 2020-09-10 03:52:35 more
  • 詳細解讀電力系統各種對時方式

    詳細解讀電力系統各種對時方式 詳細解讀電力系統各種對時方式 安徽京準電子科技官微——ahjzsz,更多資料請添加VX 衛星同步時鐘是我京準公司開發研制的應用衛星授時時技術的標準時間顯示和發送的裝置,該裝置以M國全球定位系統(GLOBAL POSITIONING SYSTEM,縮寫為GPS)或者我國北 ......

    uj5u.com 2020-09-10 03:52:45 more
  • 如何保證外包團隊接入企業內網安全

    不管企業規模的大小,只要企業想省錢,那么企業的某些服務就一定會采用外包的形式,然而看似美好又經濟的策略,其實也有不好的一面。下面我通過安全的角度來聊聊使用外包團的安全隱患問題。 先看看什么服務會使用外包的,最常見的就是話務/客服這種需要大量重復性、無技術性的服務,或者是一些銷售外包、特殊的職能外包等 ......

    uj5u.com 2020-09-10 03:52:57 more
  • PHP漏洞之【整型數字型SQL注入】

    0x01 什么是SQL注入 SQL是一種注入攻擊,通過前端帶入后端資料庫進行惡意的SQL陳述句查詢。 0x02 SQL整型注入原理 SQL注入一般發生在動態網站URL地址里,當然也會發生在其它地發,如登錄框等等也會存在注入,只要是和資料庫打交道的地方都有可能存在。 如這里http://192.168. ......

    uj5u.com 2020-09-10 03:55:40 more
  • [GXYCTF2019]禁止套娃

    git泄露獲取原始碼 使用GET傳參,引數為exp 經過三層過濾執行 第一層過濾偽協議,第二層過濾帶引數的函式,第三層過濾一些函式 preg_replace('/[a-z,_]+\((?R)?\)/', NULL, $_GET['exp'] (?R)參考當前正則運算式,相當于匹配函式里的引數 因此傳遞 ......

    uj5u.com 2020-09-10 03:56:07 more
  • 等保2.0實施流程

    流程 結論 ......

    uj5u.com 2020-09-10 03:56:16 more
最新发布
  • 使用Django Rest framework搭建Blog

    在前面的Blog例子中我們使用的是GraphQL, 雖然GraphQL的使用處于上升趨勢,但是Rest API還是使用的更廣泛一些. 所以還是決定回到傳統的rest api framework上來, Django rest framework的官網上給了一個很好用的QuickStart, 我參考Qu ......

    uj5u.com 2023-04-20 08:17:54 more
  • 記錄-new Date() 我忍你很久了!

    這里給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 大家平時在開發的時候有沒被new Date()折磨過?就是它的諸多怪異的設定讓你每每用的時候,都可能不小心踩坑。造成程式意外出錯,卻一下子找不到問題出處,那叫一個煩透了…… 下面,我就列舉它的“四宗罪”及應用思考 可惡的四宗罪 1. Sa ......

    uj5u.com 2023-04-20 08:17:47 more
  • 使用Vue.js實作文字跑馬燈效果

    實作文字跑馬燈效果,首先用到 substring()截取 和 setInterval計時器 clearInterval()清除計時器 效果如下: 實作代碼如下: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta ......

    uj5u.com 2023-04-20 08:12:31 more
  • JavaScript 運算子

    JavaScript 運算子/運算子 在 JavaScript 中,有一些運算子可以使代碼更簡潔、易讀和高效。以下是一些常見的運算子: 1、可選鏈運算子(optional chaining operator) ?.是可選鏈運算子(optional chaining operator)。?. 可選鏈操 ......

    uj5u.com 2023-04-20 08:02:25 more
  • CSS—相對單位rem

    一、概述 rem是一個相對長度單位,它的單位長度取決于根標簽html的字體尺寸。rem即root em的意思,中文翻譯為根em。瀏覽器的文本尺寸一般默認為16px,即默認情況下: 1rem = 16px rem布局原理:根據CSS媒體查詢功能,更改根標簽的字體尺寸,實作rem單位隨螢屏尺寸的變化,如 ......

    uj5u.com 2023-04-20 08:02:21 more
  • 我的第一個NPM包:panghu-planebattle-esm(胖虎飛機大戰)使用說明

    好家伙,我的包終于開發完啦 歡迎使用胖虎的飛機大戰包!! 為你的主頁添加色彩 這是一個有趣的網頁小游戲包,使用canvas和js開發 使用ES6模塊化開發 效果圖如下: (覺得圖片太sb的可以自己改) 代碼已開源!! Git: https://gitee.com/tang-and-han-dynas ......

    uj5u.com 2023-04-20 08:01:50 more
  • 如何在 vue3 中使用 jsx/tsx?

    我們都知道,通常情況下我們使用 vue 大多都是用的 SFC(Signle File Component)單檔案組件模式,即一個組件就是一個檔案,但其實 Vue 也是支持使用 JSX 來撰寫組件的。這里不討論 SFC 和 JSX 的好壞,這個仁者見仁智者見智。本篇文章旨在帶領大家快速了解和使用 Vu ......

    uj5u.com 2023-04-20 08:01:37 more
  • 【Vue2.x原始碼系列06】計算屬性computed原理

    本章目標:計算屬性是如何實作的?計算屬性快取原理以及洋蔥模型的應用?在初始化Vue實體時,我們會給每個計算屬性都創建一個對應watcher,我們稱之為計算屬性watcher ......

    uj5u.com 2023-04-20 08:01:31 more
  • http1.1與http2.0

    一、http是什么 通俗來講,http就是計算機通過網路進行通信的規則,是一個基于請求與回應,無狀態的,應用層協議。常用于TCP/IP協議傳輸資料。目前任何終端之間任何一種通信方式都必須按Http協議進行,否則無法連接。tcp(三次握手,四次揮手)。 請求與回應:客戶端請求、服務端回應資料。 無狀態 ......

    uj5u.com 2023-04-20 08:01:10 more
  • http1.1與http2.0

    一、http是什么 通俗來講,http就是計算機通過網路進行通信的規則,是一個基于請求與回應,無狀態的,應用層協議。常用于TCP/IP協議傳輸資料。目前任何終端之間任何一種通信方式都必須按Http協議進行,否則無法連接。tcp(三次握手,四次揮手)。 請求與回應:客戶端請求、服務端回應資料。 無狀態 ......

    uj5u.com 2023-04-20 08:00:32 more