关于javascript:WebGL着色器渲染小游戏实战

4次阅读

共计 11923 个字符,预计需要花费 30 分钟才能阅读完成。

我的项目起因

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

起因是看到某些小游戏广告,感觉机制乏味,实现起来应该也不会很简单,就尝试本人开发一个。

游戏非常简略,相似泡泡龙一样的从屏幕下方两头射出不同色彩大小的泡泡,泡泡上浮到顶部,雷同色彩的泡泡能够合并成大一级的不同色彩泡泡。简略说就是一个高低反过来的合成大西瓜。

较特地的中央是为了体现泡泡的质感,在色彩雷同的泡泡凑近时,会有水滴外表先合并的成果,这一部分就须要用到着色器渲染来实现了。

我的项目构造

先对逻辑分层

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

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

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

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

这样分层的益处是,各个模块能够独立替换 / 批改;例如在 GLSL 渲染器开发实现前,能够替换成其余的渲染器,如 2D canvas 渲染器,甚至应用 HTML DOM 来渲染。

结构图如下:

游戏逻辑实现

游戏业务逻辑 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)
  }
}

开发定制渲染器

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

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

如果咱们把眼镜脱下,或焦点放远一点,大略能够看到这样的图像:

看到这里可能就有人猜到是怎么实现的了。

是的,就是利用两个边缘径向突变亮度的圆形,在它们的突变边缘叠加的地位,亮度的相加能达到圆形核心的水平。

而后在这个突变边缘的图形上加一个阶跃函数滤镜(低于某个值置为 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);

如上文所说的,咱们还须要给这个图像加一个阶跃函数滤镜;但咱们不能在下面的片段着色器上间接采纳阶跃函数解决输入,因为它是对每个顶点独立渲染的,不会带有其余顶点在以后顶点范畴内的信息,也就不会有后面说的「亮度相加」的计算可能。

一个思路是将下面着色器的渲染图像作为一个纹理,在另一套着色器上做阶跃函数解决,作最初理论输入。

对于这样的多级解决,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);
  }
}

不同类型的泡泡区别

在上一节中,实现了游戏里不同地位、不同大小的泡泡在画布上的绘制,也实现了泡泡之间粘合的成果,然而所有的泡泡都是一样的色彩,而且不能合并的泡泡之间也有粘合的成果,这不是咱们想要的成果;

在这一节,咱们把这些不同类型泡泡做出区别。

要辨别各种类型的泡泡,能够在第一套着色器中只传入某个类型的泡泡信息,反复绘制出纹理供第二套场景着色器应用。但每次只绘制一个类型的泡泡会减少很多的绘制次数。

其实在上一节的场景着色器中,只应用了红色通道,而绿色、蓝色通道的值和红色是一样的:

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),不定时推送文章。

正文完
 0