2

WebGL着色器渲染小游戏实战

 2 years ago
source link: https://my.oschina.net/o2team/blog/5290493
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

WebGL着色器渲染小游戏实战 - 凹凸实验室 - OSCHINA - 中文开源技术交流社区

经过对 GLSL 的了解,以及 shadertoy 上各种项目的洗礼,现在开发简单交互图形应该不是一个怎么困难的问题了。下面开始来对一些已有业务逻辑的项目做GLSL渲染器替换开发。

起因是看到某些小游戏广告,感觉机制有趣,实现起来应该也不会很复杂,就尝试自己开发一个。

https://oscimg.oschina.net/oscnet/up-2b7391f7cf742f83a0f1edfb73334b36618.jpg

游戏十分简单,类似泡泡龙一样的从屏幕下方中间射出不同颜色大小的泡泡,泡泡上浮到顶部,相同颜色的泡泡可以合并成大一级的不同颜色泡泡。简单说就是一个上下反过来的合成大西瓜。

较特别的地方是为了表现泡泡的质感,在颜色相同的泡泡靠近时,会有水滴表面先合并的效果,这一部分就需要用到着色器渲染来实现了。

先对逻辑分层

最上层为游戏业务逻辑Game,管理游戏开始、结束状态,响应用户输入,记录游戏分数等。

其次为游戏逻辑驱动层Engine,管理游戏元素,暴露可由用户控制的动作,引用渲染器控制游戏场景渲染更新。

再往下是物理引擎模块Physics,管理游戏元素之间的关系,以及实现Engine需要的接口。

与引擎模块并列的是渲染器模块Renderer,读取从Engine输入的游戏元素,渲染游戏场景。

这样分层的好处是,各个模块可以独立替换/修改;例如在GLSL渲染器开发完成前,可以替换成其他的渲染器,如2D canvas渲染器,甚至使用HTML DOM来渲染。

结构图如下:

https://oscimg.oschina.net/oscnet/up-2591a72345efa318f5c99f540e0bd538fb0.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://oscimg.oschina.net/oscnet/up-96213ed353f6267b437f66cad11bb00185d.jpg

开发定制渲染器

接下来该说一下渲染器的实现了。

先说一下这种像是两滴液体靠近,边缘合并的效果是怎么实现的。

https://oscimg.oschina.net/oscnet/up-394694909db196cca7a2bd98d10680bb590.gif

如果我们把眼镜脱下,或焦点放远一点,大概可以看到这样的图像:

https://oscimg.oschina.net/oscnet/up-431ce7c4656194cf4ecb4db5d7e80e8c0e2.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 = 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://oscimg.oschina.net/oscnet/up-a8f0abc570308ba853bbd93f3c4a97dbad8.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://oscimg.oschina.net/oscnet/up-6eb33d0dc770ad3a5c4cf6faa66a66ebc24.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://oscimg.oschina.net/oscnet/up-6542600ff245e68f95aab07d95c029cb76a.jpg

经过以上处理,整个游戏已基本完成,在这以上可以再修改泡泡的样式、添加分数展示等的部分。

完整项目源码可以访问: https://github.com/wenxiongid/bubble

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK